diff --git a/.gitignore b/.gitignore index 851614472..b6f803a77 100644 --- a/.gitignore +++ b/.gitignore @@ -33,3 +33,12 @@ mylog.txt .idea/ .vscode/ *.swp + +# RE 5000PBR lesson local artifacts +/tools/web/.tts-venv/ +/tests/golden/_backup-*/ +/tests/golden/RE-PBR-VIDEO-001/narration/ +/tests/golden/RE-PBR-VIDEO-001/*.webm +/tests/golden/RE-PBR-VIDEO-001/diagnostics/ +*.blend1 +*~ diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp b/blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp index 9a88b4918..5b996daa0 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp +++ b/blender-5.2.0/source/blender/web_engine/web_engine_blend_reader.cpp @@ -1941,6 +1941,44 @@ std::string image_mime_type(const std::string &filepath) return "application/octet-stream"; } + +bool packed_image_dimensions(const std::string &mime_type, + const std::vector &bytes, + int64_t &width, + int64_t &height) +{ + width = 0; + height = 0; + if (mime_type == "image/png") { + if (bytes.size() < 24 || bytes[0] != 0x89 || bytes[1] != 'P' || bytes[2] != 'N' || bytes[3] != 'G') return false; + width = (int64_t(bytes[16]) << 24) | (int64_t(bytes[17]) << 16) | (int64_t(bytes[18]) << 8) | int64_t(bytes[19]); + height = (int64_t(bytes[20]) << 24) | (int64_t(bytes[21]) << 16) | (int64_t(bytes[22]) << 8) | int64_t(bytes[23]); + return width > 0 && height > 0; + } + if (mime_type == "image/jpeg") { + if (bytes.size() < 4 || bytes[0] != 0xff || bytes[1] != 0xd8) return false; + size_t offset = 2; + while (offset + 9 < bytes.size()) { + if (bytes[offset] != 0xff) { offset += 1; continue; } + const uint8_t marker = bytes[offset + 1]; + offset += 2; + if (marker == 0xd8 || marker == 0xd9) continue; + if (offset + 2 > bytes.size()) break; + const size_t length = (size_t(bytes[offset]) << 8) | size_t(bytes[offset + 1]); + if (length < 2 || offset + length > bytes.size()) break; + if ((marker >= 0xc0 && marker <= 0xc3) || (marker >= 0xc5 && marker <= 0xc7) || + (marker >= 0xc9 && marker <= 0xcb) || (marker >= 0xcd && marker <= 0xcf)) { + if (length < 7) break; + height = (int64_t(bytes[offset + 3]) << 8) | int64_t(bytes[offset + 4]); + width = (int64_t(bytes[offset + 5]) << 8) | int64_t(bytes[offset + 6]); + return width > 0 && height > 0; + } + offset += length; + } + } + return false; +} + bool image_signature_valid(const std::string &mime_type, const std::vector &bytes) { if (bytes.empty()) return false; @@ -4782,6 +4820,9 @@ json scene_ir_from_blend(const ParsedBlend &blend, float emission_strength = 1.0f; std::vector emission_color = {0.0f, 0.0f, 0.0f, 1.0f}; std::string normal_image_id; + std::string displacement_image_id; + float displacement_midlevel = 0.5f; + float displacement_scale = 0.001f; std::vector image_ids; std::vector warnings; json material_nodes = json::array(); @@ -4809,6 +4850,9 @@ json scene_ir_from_blend(const ParsedBlend &blend, node_type == "ShaderNodeBsdfPrincipled" ? "PRINCIPLED" : node_type == "ShaderNodeTexImage" ? "IMAGE_TEXTURE" : node_type == "ShaderNodeNormalMap" ? "NORMAL_MAP" : + node_type == "NodeReroute" ? "REROUTE" : + node_type == "NodeFrame" ? "FRAME" : + node_type == "ShaderNodeDisplacement" ? "DISPLACEMENT" : node_type == "ShaderNodeOutputMaterial" ? "OUTPUT" : "UNSUPPORTED"; json node_ir = {{"id", node_id}, {"type", mapped_type}, {"name", node_name.empty() ? node_type : node_name}}; auto location = read_float_array(*blend.sdna, node, "location", 2); @@ -4995,6 +5039,8 @@ json scene_ir_from_blend(const ParsedBlend &blend, std::string uv_map; double strength = 1.0; double distance = 1.0; + double midlevel = 0.5; + double scale = 0.01; bool evaluated = true; }; std::function resolve_texture = @@ -5014,6 +5060,12 @@ json scene_ir_from_blend(const ParsedBlend &blend, std::string next_socket; if (type == "NORMAL_MAP") { next_socket = "Color"; out.strength = socket_number(node, "Strength", 1.0); } else if (type == "BUMP") { next_socket = "Height"; out.strength = socket_number(node, "Strength", 1.0); out.distance = socket_number(node, "Distance", 1.0); out.terminal_type = "BUMP"; } + else if (type == "DISPLACEMENT") { + next_socket = "Height"; + out.midlevel = socket_number(node, "Midlevel", 0.5); + out.scale = socket_number(node, "Scale", 0.01); + out.terminal_type = "DISPLACEMENT"; + } else if (type == "MAPPING") { next_socket = "Vector"; out.evaluated = false; } else if (type == "SEPARATE_COLOR") { next_socket = "Color"; out.evaluated = false; } if (next_socket.empty() && node.contains("sockets")) { @@ -5076,6 +5128,43 @@ json scene_ir_from_blend(const ParsedBlend &blend, if (std::find(image_ids.begin(), image_ids.end(), resolution.image_id) == image_ids.end()) image_ids.push_back(resolution.image_id); } } + for (const json &output : material_nodes) { + if (output.value("type", "") != "OUTPUT" || !output.contains("sockets")) continue; + for (const json &socket : output["sockets"]) { + if (socket.value("direction", "") != "INPUT" || socket.value("name", "") != "Displacement" || !socket.value("linked", false)) continue; + const std::string socket_id = socket.value("id", ""); + const auto link_it = link_by_target.find(output.value("id", std::string()) + "\n" + socket_id); + if (link_it == link_by_target.end()) continue; + const std::string displacement_node_id = (*link_it->second).value("fromNodeId", std::string()); + const auto displacement_it = node_by_id.find(displacement_node_id); + if (displacement_it == node_by_id.end() || + (*displacement_it->second).value("type", "") != "DISPLACEMENT") { + warnings.push_back("displacement_terminal_unsupported"); + continue; + } + TextureResolution resolution; + if (!resolve_texture(displacement_node_id, 0, resolution)) { + warnings.push_back("displacement_binding_unresolved"); + continue; + } + const std::string key = resolution.image_id + "\nHEIGHT"; + if (!binding_keys.insert(key).second) continue; + json binding = { + {"imageId", resolution.image_id}, + {"usage", "HEIGHT"}, + {"socket", "Height"}, + {"evaluated", resolution.evaluated}, + {"midlevel", resolution.midlevel}, + {"scale", resolution.scale}, + }; + if (!resolution.uv_map.empty()) binding["uvMap"] = resolution.uv_map; + texture_bindings.push_back(std::move(binding)); + displacement_image_id = resolution.image_id; + displacement_midlevel = float(resolution.midlevel); + displacement_scale = float(resolution.scale); + if (std::find(image_ids.begin(), image_ids.end(), resolution.image_id) == image_ids.end()) image_ids.push_back(resolution.image_id); + } + } } if (node_tree_pointer && !has_principled) warnings.push_back("principled_node_not_found"); json material = { {"id", record.id}, @@ -5093,6 +5182,11 @@ json scene_ir_from_blend(const ParsedBlend &blend, {"coatRoughness", std::clamp(coat_roughness, 0.0f, 1.0f)}, {"emissionStrength", std::clamp(emission_strength, 0.0f, 1000000.0f)} }; if (!normal_image_id.empty()) material["normalImageId"] = normal_image_id; + if (!displacement_image_id.empty()) { + material["displacementImageId"] = displacement_image_id; + material["displacementMidlevel"] = displacement_midlevel; + material["displacementScale"] = displacement_scale; + } if (!image_ids.empty()) material["imageIds"] = image_ids; if (!texture_bindings.empty()) material["textureBindings"] = std::move(texture_bindings); if (!material_nodes.empty()) { @@ -5336,10 +5430,21 @@ json scene_ir_from_blend(const ParsedBlend &blend, if (!packed_record.bytes.empty()) packed_assets.emplace(record.id, packed_record.bytes); corrupt = !image_signature_valid(mime_type, packed_record.bytes); } - const int64_t width = !image_tiles.empty() ? read_integer(*blend.sdna, image_tiles.front(), "gen_x").value_or(0) : - read_integer(*blend.sdna, element, "gen_x").value_or(0); - const int64_t height = !image_tiles.empty() ? read_integer(*blend.sdna, image_tiles.front(), "gen_y").value_or(0) : - read_integer(*blend.sdna, element, "gen_y").value_or(0); + const int64_t image_width = read_integer(*blend.sdna, element, "x").value_or(0); + const int64_t image_height = read_integer(*blend.sdna, element, "y").value_or(0); + int64_t packed_width = 0; + int64_t packed_height = 0; + if (!packed_records.empty()) { + packed_image_dimensions(mime_type, packed_records.front().bytes, packed_width, packed_height); + } + const int64_t width = packed_width > 0 ? packed_width : + (source != 6 && image_width > 0 ? image_width : + (!image_tiles.empty() ? read_integer(*blend.sdna, image_tiles.front(), "gen_x").value_or(0) : + read_integer(*blend.sdna, element, "gen_x").value_or(0))); + const int64_t height = packed_height > 0 ? packed_height : + (source != 6 && image_height > 0 ? image_height : + (!image_tiles.empty() ? read_integer(*blend.sdna, image_tiles.front(), "gen_y").value_or(0) : + read_integer(*blend.sdna, element, "gen_y").value_or(0))); std::string asset_status = packed ? "PACKED" : !library_path.empty() ? "LINKED_LIBRARY_REQUIRED" : source == 4 ? "GENERATED" : source == 6 ? "UDIM_EXTERNAL" : !filepath.empty() ? "EXTERNAL" : "MISSING"; diff --git a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp index d7abb44bc..5f42b975b 100644 --- a/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp +++ b/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp @@ -24898,12 +24898,20 @@ bool web_engine_blend_main_obj_io(WebBlendMainState *state, std::filesystem::path path; ~Scratch() { std::error_code ignored; std::filesystem::remove_all(path, ignored); } } scratch{directory}; - const std::string basename = BLI_path_basename(file_name ? file_name : "scene.obj"); - if (basename.empty() || basename == "." || basename == ".." || basename.size() > 255) { + const std::string original_basename = BLI_path_basename(file_name ? file_name : "scene.obj"); + if (original_basename.empty() || original_basename == "." || original_basename == ".." || original_basename.size() > 255) { error = "IO_FORMAT_INPUT_INVALID: mesh file name is invalid"; return false; } - const std::string filename = (directory / basename).string(); + /* The browser may send non-ASCII file names (for example 猴头.glb). Blender's + * importers look the staged path up through the Emscripten VFS, so keep the + * on-disk staging name ASCII while leaving the UI's original display name + * untouched. ASCII names keep their existing on-disk name. */ + std::string staged_basename = original_basename; + for (char &character : staged_basename) { + if (static_cast(character) >= 0x80) character = '_'; + } + const std::string filename = (directory / staged_basename).string(); const int before = BLI_listbase_count(&state->main->objects); if (importing) { const auto bytes = decode_base64(base64); diff --git a/docs/status/PBR_TUTORIAL.md b/docs/status/PBR_TUTORIAL.md new file mode 100644 index 000000000..2165b3370 --- /dev/null +++ b/docs/status/PBR_TUTORIAL.md @@ -0,0 +1,72 @@ +# PBR 渲染初学者互动教程 (pbr) + +日期:2026-11-11 +状态:**功能实现并完成有头 Chromium 采集与聚焦 E2E;整体产品验收仍由用户执行** + +## 交付内容 + +- 新应用内课程 `pbr`:`/?ui=web&tutorial=pbr`(落地页)/ `?ui=web&tutorial=pbr&start=1`(编辑器中)。 +- 13 步中文教程: + `intro` → `workspace-shading` → `load-studio` → `add-suzanne` → `add-cube` → `add-torus` → + `assign-metal` → `assign-glass` → `assign-wood` → `assign-emission` → `render-setup` → `download-save` → `summary`。 +- 教程演示动作走真实产品入口:工作区切换、按名选择、PBR 资产库追加模型/分配材质、打开课程摄影棚、 + Web PBR 渲染、PNG 下载、工程保存。 +- React 接线:`TutorialContext` 增加 workspace/资产库/渲染结果;`TutorialApi` 增加工作区、选择、 + 资产追加、材质分配、打开场景、渲染、下载;`PbrRenderPanel` 支持外部请求与 `onRendered` 回传。 + +## 资产与截图 + +- 课程摄影棚:`web/app/public/tutorial/pbr/studio.blend` + - 相机 `PBR Studio Camera`、三点灯光 + Sun、深灰世界、地面;生成报告 + `tests/golden/PBR_TUTORIAL-001/studio-build.json`。 +- 教程步骤截图(有头 Chromium 实际操作): + `web/app/public/tutorial/pbr/landing.jpg`、`steps/.jpg`、`steps/-view.jpg`、 + `steps/00-assembly.jpg`(13 步 × 2 + 2)。 +- 12 张材质对比成片:`web/app/public/tutorial/pbr/renders/-.{png,jpg}` + - 模型:Suzanne、Cube、Torus;材质:Metal Brushed、Glass、Wood、Emission Cyan。 + - 这 12 张由桌面 Blender EEVEE 使用同一摄影棚/材质库渲染(`renderer: DESKTOP_BLENDER_EEVEE`), + 因浏览器 WebGL2 路径当前对部分材质仍偏暗;教程步骤截图与交互操作仍是真实有头 Chromium。 + - 生成脚本:`tools/web/generate-pbr-tutorial-renders.py`。 +- 采集报告:`tests/golden/PBR_TUTORIAL-001/headed-screenshots-latest.json` + (steps=13、renders=12、status=PASS)。 + +## 脚本 + +```bash +# 生成课程摄影棚 +build_blender_native/bin/blender --background --factory-startup \ + --python tools/web/generate-pbr-tutorial-studio.py -- \ + web/app/public/tutorial/pbr/studio.blend \ + tests/golden/PBR_TUTORIAL-001/studio-build.json + +# 生成 12 张材质对比参考图 +build_blender_native/bin/blender --background --factory-startup \ + --python tools/web/generate-pbr-tutorial-renders.py -- \ + web/app/public/tutorial/pbr/studio.blend \ + web/app/public/assets/pbr-library/pbr-library.blend \ + web/app/public/tutorial/pbr/renders + +# 有头 Chromium 采集教程步骤截图 +DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \ + node tools/web/capture-parts-tutorial-screenshots.mjs --course pbr --dev --port 5176 + +# 校验采集产物 +node tools/web/check-pbr-tutorial-captures.mjs +``` + +## 验证结果 + +- `node tools/web/check-pbr-tutorial-captures.mjs`:PASS(13 步截图齐全、12 张 PNG 1024×1024、 + 报告 status=PASS、studio SHA 一致)。 +- `web/tests/e2e/pbr-tutorial-accessibility.spec.ts`:通过(axe 无 critical/serious)。 +- `web/tests/e2e/wbv2-pbr-tutorial-production.spec.ts`:通过(13 步演示、资产库 20 卡片、 + 渲染结果、PNG/.blend 下载)。 +- `npm run typecheck`:通过。 + +## 已知限制 + +- 浏览器 WebGL2 PBR 渲染对部分材质(尤其金属/木色)偏暗;12 张教程成片改用桌面 Blender EEVEE + 生成,标注为 `DESKTOP_BLENDER_EEVEE`。交互教程与步骤截图仍为真实有头 Chromium。 +- `PBR Wood` 当前为暖色哑光材质(无贴图节点),教程文案已同步为“暖色木质”,避免把未生效的 + 纹理链当作已验证功能。 +- 14 类节点之外的任意节点、动态纹理/对象信息、EEVEE/Cycles 像素级一致性不在本教程范围。 diff --git a/docs/status/RE_PBR_LIMITS_20261112.md b/docs/status/RE_PBR_LIMITS_20261112.md new file mode 100644 index 000000000..fc45de587 --- /dev/null +++ b/docs/status/RE_PBR_LIMITS_20261112.md @@ -0,0 +1,104 @@ +# RE 5000PBR 渲染已知限制:修补与验证记录 + +日期:2026-11-12 +状态:**功能实现完成;有头 Chromium 聚焦验证通过;整体产品验收仍由用户执行** +(`IMPLEMENTED_NOT_USER_ACCEPTED`) + +## 本轮决策 + +- **Chromium 限定**:只做 Chromium 硬件 GPU 与 SwiftShader 对照;Firefox/WebKit 按仓库铁律不启动、不纳入验收。 +- **2048 高清验证 + 可选切换**:默认标准 1024/512;`re-pbr-hq` 2048 库按需加载,材质名带 `_HQ`。 +- **诊断视图 + 教学曝光**:保留原材质,增加 Final/Base Color/Normal/Roughness 与 0/+1/+2 EV。 +- **Displacement 渲染与交互视口都执行**:HEIGHT 绑定 + displacementMap;静帧有界细分,交互视口逐顶点位移。 + +## 逐条结果 + +### 1. Displacement / Height + +- 原生 reader:从 `Material Output.Displacement` 读取 `ShaderNodeDisplacement`,解析 Height 图、`Midlevel=0.5`、`Scale=0.001`; + 输出 `usage:"HEIGHT"` binding 与 `displacementImageId/displacementMidlevel/displacementScale`。 + 同时改为从 packed JPEG/PNG 头读取真实尺寸(`gen_x` 只有 1024 时仍能识别 HQ 2048)。 +- 编译器:`DISPLACEMENT` 进入 allowlist;不再删除该节点;支持 `IMAGE_TEXTURE → DISPLACEMENT.Height`、 + `DISPLACEMENT → OUTPUT.Displacement`、`VALUE/MATH → Scale/Midlevel`;HEIGHT binding 带 `scale/midlevel/uvTransform`。 +- Three.js:`applyMaterial` 设置 `displacementMap`、`displacementScale`、`displacementBias=-midlevel*scale`; + HEIGHT 强制 Non-Color,禁止回退到 BASE_COLOR;原生/编译 binding 按 usage 合并。 +- 几何:静帧渲染对 968 三角形猴头做 level 3 中点细分到 **61,952** 三角形;交互视口使用原几何逐顶点位移, + 编辑模式不细分,避免破坏拾取。 +- 教学强度:1× 保留源 Scale;10×/100× 只乘渲染时 `displacementScale`,不写材质/.blend。 +- 证据:`tests/golden/RE-PBR-LIMITS-001/displacement/report.json` + (HEIGHT/scale/midlevel/bias、968→61,952、1×/100× PNG、mean 93.96/93.87、SHA 不同)。 + +### 2. 深色材质 + +- Rendering 面板增加“视图”:Final / Base Color / Normal / Roughness;只临时替换活动对象材质,地面保留 Final。 +- 教学曝光 0/+1/+2 EV 只调整渲染曝光,不改变材质数据。 +- 证据:`tests/golden/RE-PBR-LIMITS-001/diagnostics/report.json`(3 张诊断图、曝光差异 >1)。 +- 桌面 EEVEE 参考脚本本轮未运行;EEVEE 不把 Material Output.Displacement 当几何位移, + 因此即使补参考也只用于颜色/灯光,不用于位移验收。 + +### 3. 视口相机 + +- 新增 `viewPreset`:`SCENE_CAMERA` 复用场景相机 `worldMatrix` 精确姿态;`COMPARISON` 以主体为中心 + yaw + π/6、pitch +0.18、distance ×1.1。 +- PbrRenderPanel 增加“视角预设”;默认 SCENE_CAMERA;主视口 `ViewportNavigation` 不受影响。 +- 证据:最终 20 张采集 `uniqueSha=20`;SCENE/VIEWPORT 成片不再重复; + `tests/golden/RE-PBR-RENDER-001/report.json`。 + +### 4. 重开 `ready:0:0` + +- 根因:重开时空 `textureAssets` 被当作 `textureExpected=false`,面板在 packed assets 到达前渲染并读取空状态; + 另有 worker 完成时重盖章 snapshot revision、`setSnapshot` 重发旧 `lastTextureStatus`。 +- 修复:adapter 单调 `textureRevision` + `expectedAssets`;worker 丢弃旧上传、不重发旧状态; + `still` 回传 `textureStatus`;面板从 still/最新状态取数;空列表且 expected>0 时 `pending`; + `GPUTextureStore.upload(..., {replace})` 清旧纹理;PbrRenderPanel 对当前 snapshot 做一次自愈式 cache。 +- 证据:重开 E2E `data-texture-status=ready:5:0:`,`loaded===expected`;最终采集 reopen 同为 `ready:5:0:`。 + +### 5. 硬件 GPU / 2048 / 跨浏览器 + +- Playwright 与采集脚本支持 `GPU_MODE`/`--gpu hardware`;有头 Chromium 探测到 + `ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 4060 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0)`; + 硬件 lane 若回退 SwiftShader/llvmpipe 失败关闭。 +- 同一 1024 材质:硬件 mean 95.277/std 44.758,SwiftShader mean 95.332/std 44.724; + 硬件 E2E 27s,SwiftShader 参考 35.8s(`tests/golden/RE-PBR-LIMITS-001/gpu/batch0-gpu-enable.json`)。 +- 2048 HQ:`re-pbr-hq` 10.8 MB、6 个 `_HQ` 材质、原始 packed JPEG;原生 reader 直接读 packed JPEG 尺寸, + Height 图验证 2048×2048;GPU decoded 67,633,152 bytes(64.5 MiB)、payload 2,625,921 bytes、 + loaded 5/rejected 0,低于 512 MiB。 +- 证据:`tests/golden/RE-PBR-LIMITS-001/hq/report.json`。 +- 跨浏览器:仓库规则 Chromium-only,Firefox/WebKit 未运行,记为 policy-excluded,不是工程缺口。 + +## 验证结果 + +| 检查 | 结果 | +| --- | --- | +| `npm run typecheck` | 通过 | +| `node --test tests/unit/shader-compiler.test.mjs` | 13/13 通过(含 HEIGHT/DISPLACEMENT/Value→Mapping) | +| 有头 E2E 主流程(6 材质、位移 1×/100×、诊断、曝光、相机、重开) | 通过(3.6m) | +| 有头 E2E 截断 GLB 负例 | 通过 | +| 有头 E2E 原生 HEIGHT binding | 通过(scale 0.001、midlevel 0.5) | +| 有头 E2E HQ 原生 2048 尺寸 | 通过(Height 2048×2048) | +| 有头 E2E HQ 按需加载与渲染 | 通过(67,633,152 bytes、ready:5:0) | +| `check-re-pbr-limits.mjs` | PASS(位移/诊断/HQ/GPU/相机/重开) | +| 最终有头硬件采集 | 20/20、uniqueSha 20、15 steps、0 errors | +| `check-re-pbr-lesson.mjs` | PASS | +| `check-re-pbr-library.mjs --hq` | PASS(10.8 MB、6 材质、2048) | +| PBR 资产库回归(有头硬件) | 2/2 通过 | +| PBR 教程回归(有头硬件) | 通过(48.3s) | + +## 证据路径 + +- 位移:`/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-LIMITS-001/displacement/` +- 诊断/曝光:`/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-LIMITS-001/diagnostics/` +- HQ 2048:`/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-LIMITS-001/hq/` +- GPU 启用:`/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-LIMITS-001/gpu/batch0-gpu-enable.json` +- 20 张硬件成片:`/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-RENDER-001/` +- 统一检查器:`/home/mes123456/workinf_Blender_Wasm/tools/web/check-re-pbr-limits.mjs` +- 教案:`/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/render-lesson.html` + +## 未验证 / 限制 + +- 源 Displacement Scale=0.001;1× 很细微,100× 是教学放大,不代表源材质变强。 +- 静帧细分到 61,952;交互视口是原几何逐顶点位移,未做交互细分。 +- 未生成桌面 EEVEE 参考;EEVEE 不作为位移参考。 +- 2048 HQ 的 mipmap 可能使实际显存比 base-level 预算高约 1/3;仅按需加载。 +- 硬件 GPU lane 依赖 `DISPLAY=:1` 和 NVIDIA 驱动;无独显时需显式 SwiftShader。 +- 未做 Firefox/WebKit;用户实际使用验收仍待执行。 diff --git a/docs/status/RE_PBR_RENDER_20261111.md b/docs/status/RE_PBR_RENDER_20261111.md new file mode 100644 index 000000000..3885c11d5 --- /dev/null +++ b/docs/status/RE_PBR_RENDER_20261111.md @@ -0,0 +1,127 @@ +# Web Blender:RE 5000PBR × 猴头 GLB 渲染与初学者 HTML 教案 + +日期:2026-11-11 +状态:**功能实现完成;有头 Chromium 聚焦验证通过;整体产品验收仍由用户执行** +后续:已知限制的修补与验证见 [`RE_PBR_LIMITS_20261112.md`](RE_PBR_LIMITS_20261112.md)。 +(`IMPLEMENTED_NOT_USER_ACCEPTED`) + +## 交付内容 + +### 1. RE 5000PBR 派生资产库 + +- `tools/web/generate-re-pbr-library.py`:从 + `re/5000PBR精简版/5000+ PBR catalog.blend` 的工作副本派生 6 个材质;标记 5 个已标记材质 + + 未标记的 `Black_Anti_Slip_grip`;统一挂到 `Materials/RE5000PBR` catalog。 +- 保留原节点图(`FRAME`、`REROUTE`、`DISPLACEMENT`、`MAPPING`、`VALUE` 与四张 `IMAGE_TEXTURE`), + 只把贴图降采样:Base Color/Normal 1024²,Roughness/Height 512²,并重新 pack 为 JPEG。 +- 产物:`web/app/public/assets/re-pbr/` + - `re-pbr-library.blend`:6 个 MATERIAL,SHA-256 `d67f5a058c154626dfc7171c5bc7fc254b8ee183fea8740261b9711b3309d782`。 + - `manifest.json`、`preview-manifest.json`、`desktop-canonical.json`、`blender_assets.cats.txt`、 + 6 张 256² 预览。 + - 库大小 3.7 MiB,packed 贴图约 3.06 MiB(源 10.7 MiB)。 +- `tools/web/check-re-pbr-library.mjs`:校验 6 个材质、预览、SHA、尺寸、JPEG 格式、色彩空间, + 并只读复检节点图仍包含 `FRAME/REROUTE/DISPLACEMENT/MAPPING/VALUE` 与四张贴图。 + +### 2. 多库播种与合并(资产面板 20 个资产) + +- `web/protocol/asset-catalog-v2.ts`:新增 `mergeAssetCatalogManifests`,按 catalog/asset/library id + 去重追加、bump revision 并重新校验。 +- `web/app/src/storage/asset-catalog-store.ts`:新增 `mergeAssetCatalogManifest`,事务内重读原始行、 + 检测 revision 漂移、更新/删除预览回执,避免旧回执失效。 +- `web/protocol/storage.ts`、`StorageClient.ts`、`storage.worker.ts`:新增合并命令; + seed/读取 manifest/预览的客户端超时提高到 30–60 s。 +- `web/app/src/app/pbr-library-seed.ts`:`PBR_LIBRARY_SOURCES` 支持 + `pbr-essentials-v1`(14)与 `re-pbr-v1`(6);首装先 seed 主库,再对缺失库 + `seed(repair=true)` + merge;单库失败只降级该库并保留既有资产。 +- `AssetLibraryPanel.tsx`:卡片补 `data-testid`/`data-asset-id`/`data-library`;新增“资产来源”筛选; + 缩略图改为 eager,20 张预览全部加载。 +- 既有 PBR 教程/测试的资产数从 14 更新到 20;PBR 教程原有 12 张渲染矩阵保持不变。 + +### 3. 渲染正确性修复 + +- **快照后重绑纹理与环境**:`viewport-render.worker.ts` 拆分 + `reapplyCurrentTextures()`(只从已上传 store 重绑)与 `applyTextureAssets()`(upload + 重绑); + `setSnapshot` 末尾重绑,不再在每次分配材质时重新解码全部贴图。 +- **HDRI 背景保护**:只有 world 确有环境贴图时才让 `applyEnvironment` 覆盖背景, + flat world color 不再覆盖已应用的 HDRI。 +- **纹理就绪屏障**:`offscreen-viewport.ts` 增加按 snapshot revision 对齐的 + `whenTexturesReady()`;`renderStill` 等待纹理与环境应用完成;空纹理也明确回传状态。 + 面板用该屏障替换固定 150 ms 延时。 +- **原生 reader**:`web_engine_blend_reader.cpp` 不再把 + `NodeReroute/NodeFrame/ShaderNodeDisplacement` 统一映射为 `UNSUPPORTED`,而是输出 + `REROUTE/FRAME/DISPLACEMENT`。 +- **编译器归一化**:`compileMaterialGraph` 渲染路径与 `compileShaderGraph` 共用 + `normalizeShaderGraph`:折叠 REROUTE、移除 FRAME、删除 DISPLACEMENT 节点及全部连线; + 对不含这些节点的图严格 no-op。 +- **编译器扩展**:支持 `IMAGE_TEXTURE → Roughness/Metallic/Alpha`、`IMAGE_TEXTURE → + SEPARATE_COLOR`、`VALUE → MAPPING.Scale/Rotation/Location`(标量广播);Mapping 的 + `uvTransform` 与原生 `textureBindings` 按 usage 合并,不再被原生绑定覆盖。 +- `SceneIR` 运行时校验同步允许 `REROUTE/FRAME/DISPLACEMENT`。 +- `App.tsx` 的材质分配在对象无槽时用 `slotIndex=-1` 追加槽,避免先创建空白材质导致 + `name.001` 冲突;重开工程后材质槽保留。 +- `web/app/public/engine-manifest.json` 的 `single` 变体改指向重新构建的 + `/vendor/blender/web_engine.{js,wasm}`,修复旧的 `vendor/blender/single/web_engine.wasm` + 哈希不匹配(`MANIFEST_REJECTED`)。 + +### 4. 有头 Chromium 测试与 20 张成片 + +- `web/tests/e2e/re-pbr-render-workflow.spec.ts`: + - 打开摄影棚 → 导入 `monkey-head.glb` → Shading 20 卡/6 个 RE 材质 → 逐个分配 6 材质 → + 材质球 `COMPILED` → 1024/场景渲染 → `data-texture-status` ready → PNG 下载 → + 保存 .blend → 重开并确认材质槽仍存在。 + - 截断 GLB 负例:对象数不变。 +- `tools/web/capture-re-pbr-lesson.mjs`:有头 Chromium(`headless:false`)生成 + - `tests/golden/RE-PBR-RENDER-001/renders/*.png`:6 材质 ×(1024 场景 + 1024 视口 + 512 场景) + + 未分配基线 + 保存重开复渲染 = 20 张; + - `web/app/public/tutorial/pbr/lesson/renders/*.jpg` 与 `steps/*.jpg`、`landing.jpg`; + - `report.json`(尺寸、SHA、mean/std、textureStatus、材质、相机、浏览器/DISPLAY)。 +- `tools/web/check-re-pbr-lesson.mjs`:PASS(20 张、6 材质 SHA 两两不同、基线不同、 + PNG 尺寸、textureStatus ready、GLB SHA、教案 HTML 无 inline script/style/on*、无远程 URL、 + 所有图片存在)。 + +### 5. 初学者 HTML 教案 + +- `web/app/public/tutorial/pbr/render-lesson.html` + `render-lesson.css` + - 12 节:目标、术语、准备、导入 GLB、资产库、六种材质逐课、渲染设置、下载/保存/重开、 + 20 张矩阵、黑图排查、限制、附录。 + - 静态 HTML;外链 CSS;无 inline `