feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
Some checks failed
M6 deployable RC / quick (push) Has been cancelled
M6 deployable RC / chromium (push) Has been cancelled
M6 deployable RC / release (push) Has been cancelled

- Add 11:07 Chinese-narrated lesson video, VTT, poster and 13 chapter screenshots.
- Regenerate 20-render WebGL2 matrix and 15 operation captures on RTX 4060.
- Fix WASM staging for non-ASCII import filenames (猴头.glb); rebuild web_engine.
- Harden lesson video recording pipeline; fix stale lesson-link E2E selector.
- Add RE PBR derived libraries, lesson page, evidence and status report.

Co-Authored-By: Claude Code <noreply@anthropic.com>
This commit is contained in:
mes123456
2026-11-12 21:49:24 +08:00
parent a078648297
commit d5dd647881
276 changed files with 9632 additions and 314 deletions

9
.gitignore vendored
View File

@@ -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
*~

View File

@@ -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<uint8_t> &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<uint8_t> &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<float> 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<std::string> image_ids;
std::vector<std::string> 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<bool(const std::string &, int, TextureResolution &)> 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";

View File

@@ -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<unsigned char>(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);

View File

@@ -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/<stepId>.jpg`、`steps/<stepId>-view.jpg`、
`steps/00-assembly.jpg`(13 步 × 2 + 2)。
- 12 张材质对比成片:`web/app/public/tutorial/pbr/renders/<model>-<material>.{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 像素级一致性不在本教程范围。

View File

@@ -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;用户实际使用验收仍待执行。

View File

@@ -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 `<style>`/`<script>`/`on*`;同源图片;打印样式。
- 所有截图来自本次有头 Chromium 实际操作;20 张成片全部标注 Web/WebGL2。
- React 入口:
- `PbrLessonPanel.tsx` 挂在 Rendering 工作区:打开图文教案、载入练习摄影棚、导入猴头 GLB、
显示 20 资产 / 6 个 RE 材质。
- `TutorialCourse.lessonUrl`;PBR 落地页导航增加“渲染图文教案”;帮助菜单增加“PBR 渲染教案”。
## 验证结果
| 检查 | 结果 |
| --- | --- |
| `node tools/web/check-re-pbr-library.mjs` | PASS(6 材质、6 预览、SHA/尺寸/JPEG/节点图) |
| `node --test web/tests/unit/shader-compiler.test.mjs` | 13/13 通过(含 REROUTE/FRAME/DISPLACEMENT、Value→Mapping、Roughness/Metallic 新用例) |
| `npm run typecheck` | 通过 |
| `npm run build` | 通过 |
| `npx playwright test --headed tests/e2e/re-pbr-render-workflow.spec.ts` | 2/2 通过(有头 Chromium) |
| `npx playwright test --headed tests/e2e/pbr-asset-library-production.spec.ts tests/e2e/wbv2-pbr-tutorial-production.spec.ts` | 3/3 通过(20 资产回归) |
| `node tools/web/check-re-pbr-lesson.mjs` | PASS(20 张 / 15 步 / 6 材质 / 基线均值 94.60) |
| 部署服务器 + 有头 Chromium 打开 `render-lesson.html` | 标题、35 张图片(eager/decode 后)全部加载;无 4xx;CSP 头生效 |
## 证据路径
- 20 张渲染:`/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-RENDER-001/renders/`
- 采集报告:`/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-RENDER-001/report.json`
- 教案:`/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/render-lesson.html`
- 教案截图:`/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/`
- 派生库:`/home/mes123456/workinf_Blender_Wasm/web/app/public/assets/re-pbr/`
- GLB fixture:`/home/mes123456/workinf_Blender_Wasm/tests/files/web/pbr-render-lesson/monkey-head.glb`
(SHA-256 `60267d607b63ceb5bfc972342a9ff17310d6978ff632f240ecb963c4d5a7e003`)
- 引擎 wasm:`web_engine.wasm` SHA-256 `909290259e2911af49f1d6f308e1e05e01f62aa1841847b878bcd3191b98c24c`
## 已知限制 / 未验证项
- Web 渲染器不执行 Displacement;Height 贴图保留但不产生位移。
- 六种 RE 材质都是深色砖石/防滑表面;成片整体偏暗是材质本身,不是黑图。
- 当前视口相机默认与场景相机对齐,所以 1024 场景/视口成片可能相同;移动视口后再渲染即可看到差异。
- 保存重开后 `data-texture-status` 的诊断字符串曾出现 `ready:0:0`(图像本身与保存前一致);
这是诊断计数时序问题,已记录,不影响重开渲染结果。
- 派生贴图为 1024/512;未做 2048 原始贴图对照。
- 未做硬件 GPU(非 SwiftShader)对照、Firefox/WebKit、移动端。
- 未验证用户实际使用验收;不把本次单测/E2E/采集 PASS 当作整体产品验收。

View File

@@ -0,0 +1,140 @@
# RE 5000PBR × 猴头 GLB:渲染全面测试、HTML 教案与中文配音视频
日期:2026-11-12
状态:**功能实现完成;聚焦验证通过;整体产品验收仍由用户执行**
(`IMPLEMENTED_NOT_USER_ACCEPTED`)
## 本轮目标与范围
用户要求:在 Web Blender 中使用 `/home/mes123456/workinf_Blender_Wasm/re/5000PBR精简版`
的资产,对 `/home/mes123456/workinf_Blender_Wasm/re/猴头.glb` 做渲染功能全面测试;把操作过程截屏写成
初学者 HTML 教案;制作完整中文配音视频;发现的缺口按桌面 Blender 源码修 Web 移植,UI 缺口用 React;
需要时本地重编 WASM;全部使用绝对路径。
用户确认的范围:
- **只修实测阻塞**,不新增标准渲染设置 UI(宽高比、采样、透明背景、HDRI)。
- **只测 WebGL2 PBR**,不纳入本地原生 EEVEE/WASM(F12)或服务端 Blender。
前序批次已交付 RE 5000PBR 派生库、HTML 教案、20 张成片和 15 张操作截图;本轮补齐缺失的视频链路,
并修复实测暴露的一个真实导入缺口。
## 交付物
### 1. 中文配音视频(本轮新增)
| 项目 | 路径 / 值 |
| --- | --- |
| MP4 | `/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4` |
| 字幕 | `/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video/re-pbr-lesson.zh-Hans.vtt` |
| 封面 | `/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video/poster.jpg` |
| 时长 | 667.04 s(11:07),13 章 |
| 编码 | H.264 / AAC,1280×720,faststart |
| MP4 SHA-256 | `6a5b3b51c52945d0594a984ad6b5ff2e557ec319c574679673bd8d6d2a8a2916` |
| 旁白混音 SHA-256 | `adc2658938ad48b278aa434cb9635343491f7241f4632b4af6c9baa9271b9220` |
| VTT SHA-256 | `4199af3a9c1ad501ebd7832e43693d43d2bbd30a646a8285343eefc08a237aa3` |
| 封面 SHA-256 | `6461410e032de2c92f3f898873d9dca9834f2538e65cd0e4b2d8f3c443d0f444` |
| 旁白 | `zh-CN-XiaoxiaoNeural`,13 章,13 条 VTT cues |
| 章节截图 | `/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video-steps/*.jpg`(13 张,1600×900) |
| 录制证据 | `/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/{screencast.webm,timeline.json,record-report.json,narration/,diagnostics/}` |
视频由有头 Chromium(`DISPLAY=:1`)真实操作录制,GPU 探测为
`ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 4060 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0)`。
章节:开场、载入摄影棚、导入猴头 GLB、认识 Shading/RE 资产库、分配六种材质、设置渲染、
渲染与下载 PNG、保存与重开、诊断视图与教学曝光、位移与细分、对比视角、高清 2048 贴图、总结。
### 2. HTML 初学者教案
`/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/render-lesson.html`
+ `render-lesson.css`。13 节,75 个同源引用,**0 个断链**;包含真实操作截图、六种材质逐课、
20 张成片矩阵、黑图排查表和限制说明。视频、字幕、封面已嵌入。
### 3. 全面渲染测试证据(当前 wasm 重跑)
| 项目 | 路径 / 值 |
| --- | --- |
| 20 张成片 | `/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-RENDER-001/renders/*.png` |
| 采集报告 | `/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-RENDER-001/report.json` |
| 报告结果 | `status=PASS`、20 renders、15 steps、0 errors、20 个唯一 SHA |
| GPU | `ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 4060 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0)` |
| GLB SHA-256 | `60267d607b63ceb5bfc972342a9ff17310d6978ff632f240ecb963c4d5a7e003`(`re/猴头.glb`) |
| 纹理状态 | 基线 `ready:1:0:`;六材质 `ready:5:0:`;重开 `ready:5:0:` |
| 材质编译 | 六个 RE 材质均 `COMPILED` |
| 当前 wasm | `/home/mes123456/workinf_Blender_Wasm/web/app/public/vendor/blender/web_engine.wasm` SHA-256 `31550b374cc0127c9b5468cebe65be37b8558c1604a28e99ec73a34be96c13f3` |
矩阵:基线 1024;6 材质 ×(1024 场景 + 1024 视口 + 512 场景);保存/重开后复渲染。
进阶证据沿用 `/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-LIMITS-001/`:
位移 1×/100×(968 → 61,952 三角形,mean delta 0.087)、诊断视图 3 张、+2 EV、对比视角、
HQ 2048(10.8 MB 库,67,633,152 bytes 解码);`check-re-pbr-limits.mjs` 本轮 PASS。
## 本轮修复的真实缺口
### 非 ASCII 文件名 GLB 导入失败(P0,已修复)
- **现象**:用真实文件入口打开 `/home/mes123456/workinf_Blender_Wasm/re/猴头.glb` 时,对象数保持 6,
控制台报 `RuntimeError: Error: Please select a file`;ASCII 夹具 `monkey-head.glb` 正常。
- **根因**:`/home/mes123456/workinf_Blender_Wasm/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp`
的 `web_engine_blend_main_obj_io()` 原先把浏览器传来的原始文件名直接作为 Emscripten 虚拟文件系统的暂存名。
`猴头.glb` 的 UTF-8 文件名在 WASM 侧的 VFS 路径解析中不可用,glTF 导入器拿到不可解析路径后报
“Please select a file”。
- **修复**:暂存到磁盘的文件名把非 ASCII 字节替换为 `_`(例如 `______.glb`),扩展名与 ASCII 文件名保持不变;
UI 仍显示浏览器提供的原始文件名。桌面 Blender 本身支持 UTF-8 路径,本修复只影响 Web 的 WASM 暂存层。
- **构建**:`cmake --build /home/mes123456/workinf_Blender_Wasm/build_web_blender6 --target web_engine -j8`
→ `node /home/mes123456/workinf_Blender_Wasm/tools/web/sync-web-engine-runtime.mjs`;
新 wasm SHA-256 见上表。
- **验证**:视频“导入猴头 GLB”章节真实导入成功,状态栏 `Engine: GLB imported r2 (7 objects)`,
活动对象 Suzanne;20 张采集报告 `glbSha256` 与 `re/猴头.glb` 一致。
### 视频录制脚本阻塞(工具链,已修复)
- `tools/web/record-re-pbr-lesson-video.mjs` 原先的 `openStudio()` 依赖面板按钮路径,失败时不会回退;
失败前不保存视频/时间线。本轮增加:`--out` 生效、只清理 `screencast.webm`、保留原始 webm、
服务器退出检测、面板不可用时回退到 `[data-testid="blend-file-input"]`、保存对话框处理、
每章失败截图与部分时间线保留、失败时仍保存视频、硬件 GPU 回退即失败、时长 7–12 分钟守卫。
- 验证:13 章完整录制 `status=PASS`、0 errors、667.04 s。
### E2E 文案漂移(测试修正,非产品缺口)
- `web/tests/e2e/re-pbr-render-workflow.spec.ts` 仍按旧文案“打开图文教案”查找链接;面板现为
“打开图文/视频教案”。已改为正则 `/打开图文/`,主流程用例重跑通过(3.6 分钟)。
## 验证结果
| 检查 | 结果 |
| --- | --- |
| `node tools/web/check-re-pbr-library.mjs` | PASS(6 材质、6 预览、3,707,202 bytes) |
| `node tools/web/check-re-pbr-library.mjs --hq` | PASS(6 材质、10,846,909 bytes、2048) |
| `node tools/web/check-re-pbr-lesson.mjs` | PASS(20 renders、15 steps、6 材质、0 断链) |
| `node tools/web/check-re-pbr-limits.mjs` | PASS(位移/诊断/HQ/GPU/重开) |
| `node tools/web/check-re-pbr-video.mjs` | PASS(13 章、13 cues、667.04 s、1280×720、faststart) |
| 有头 E2E `re-pbr-render-workflow.spec.ts` | **5/5 通过**(6.8 分钟;主流程 6.3 分钟,截断 GLB 负例、HEIGHT 位移、HQ 按需加载、HQ 2048 尺寸均通过) |
| 20 张采集 | PASS(20/20、unique SHA 20、0 errors、RTX 4060) |
| 教案引用 | 75/75 存在,0 断链,无远程 URL |
| 教案页面冒烟(Chromium,dev) | 56 张图片全部解码、视频 `duration=667.04`/`readyState=4`、VTT 200 + 13 cues、无 4xx |
| 生产构建 | `npm run build` PASS(typecheck + vite build;`web/dist` 含视频、字幕、poster、13 张章节截图、新 wasm) |
| 教案页面冒烟(生产 dist + 部署服务) | 56 张图片全部解码、视频 667.04 s、VTT 13 cues、无 4xx |
| 视频音频 | AAC 单声道 48 kHz;`volumedetect` mean −23.4 dB、max −2.6 dB(非静音) |
## 未验证 / 限制
- Web 渲染是 three.js WebGL2 PBR(`physical-v1`),不是 EEVEE/Cycles 路径追踪;玻璃/金属/折射/GI
与桌面物理精度不同。本机原生 EEVEE/WASM(F12)与服务端 Blender 未验证。
- 六种 RE 材质都是深色砖石/防滑表面,整体偏暗是材质本身;用诊断视图和教学曝光判断,而不是只看亮度。
- 标准渲染设置 UI(非方形宽高比、采样/抗锯齿、透明胶片、HDRI/世界环境编辑)未在本轮补齐;
用户选择“只修实测阻塞”,这些记录为已知限制。
- HQ 2048 为按需加载;mipmap 可能使实际显存比 base-level 预算高约 1/3。
- 按仓库策略只验证 Chromium;Firefox/WebKit、移动端未运行。
- 视频旁白按 13 章覆盖操作步骤;材质原生追加、PBR 渲染、HQ 加载的等待段保留真实耗时,
部分章节为静默操作画面(未做变速剪辑),这是为了不伪造操作时延。
- 未做用户实际使用验收;本轮的检查器/E2E/录制 PASS 不等于整体产品验收。
## 证据路径
- 视频:`/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video/`
- 视频章节截图:`/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video-steps/`
- 教案:`/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/render-lesson.html`
- 20 张成片与报告:`/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-RENDER-001/`
- 视频录制/混音证据:`/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/`
- 进阶限制证据:`/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-LIMITS-001/`
- 本轮源码修复:`/home/mes123456/workinf_Blender_Wasm/blender-5.2.0/source/blender/web_engine/web_engine_main_state.cpp`
- 录制脚本:`/home/mes123456/workinf_Blender_Wasm/tools/web/record-re-pbr-lesson-video.mjs`

View File

@@ -8,7 +8,7 @@ when every response, including errors and SPA fallbacks, preserves these headers
| `Cross-Origin-Opener-Policy` | `same-origin` |
| `Cross-Origin-Embedder-Policy` | `require-corp` |
| `Cross-Origin-Resource-Policy` | `same-origin` |
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self' blob:; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'` |
| `Content-Security-Policy` | `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; style-src-attr 'unsafe-inline'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self' blob:; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'` |
Production uses HTTPS. Loopback development may use `http://127.0.0.1`; `file://` is unsupported.
Runtime assets are same-origin. A reverse proxy must not strip the isolation, range, cache, MIME or
@@ -204,7 +204,7 @@ NanoVDB is `application/x-nanovdb`.
## Range and validators
`.wasm`, `.blend` and `.nvdb` accept a single `bytes` range. A valid range returns `206` with
`.wasm`, `.blend`, `.nvdb`, `.mp4` and `.webm` accept a single `bytes` range. A valid range returns `206` with
`Accept-Ranges`, `Content-Range`, `Content-Length` and a strong content SHA-256 ETag. An invalid or
unsatisfied range returns `416`. An `If-Range` mismatch ignores the range and returns the complete
resource with status `200`; partial bytes from a different revision must never be combined.

View File

@@ -2,20 +2,47 @@
"schemaVersion": 1,
"product": "Web Blender Modeler V1",
"browserScope": "Chromium",
"privateDeployment": { "scope": "current Linux host, single authorized user", "usernameEnvironment": "WEB_BLENDER_USERNAME", "defaultUsername": "owner", "passwordEnvironment": "WEB_BLENDER_PASSWORD", "authentication": "HTTP Basic over HTTPS", "productionIngress": "HTTPS reverse proxy preserving Host and Authorization; HTTP listener remains loopback by default", "unauthenticatedExternalBinding": "reject", "authenticatedCacheControl": "private, no-store", "crossOriginMutation": "reject", "validation": "NOT_RUN", "acceptance": "NOT_CLAIMED" },
"privateDeployment": {
"scope": "current Linux host, single authorized user",
"usernameEnvironment": "WEB_BLENDER_USERNAME",
"defaultUsername": "owner",
"passwordEnvironment": "WEB_BLENDER_PASSWORD",
"authentication": "HTTP Basic over HTTPS",
"productionIngress": "HTTPS reverse proxy preserving Host and Authorization; HTTP listener remains loopback by default",
"unauthenticatedExternalBinding": "reject",
"authenticatedCacheControl": "private, no-store",
"crossOriginMutation": "reject",
"validation": "NOT_RUN",
"acceptance": "NOT_CLAIMED"
},
"transport": {
"production": "https",
"localDevelopment": "http://127.0.0.1",
"fileProtocolSupported": false,
"sameOriginRuntimeAssets": true
},
"methods": ["GET", "HEAD"],
"methods": [
"GET",
"HEAD"
],
"serverOperator": {
"path": "/api/server-operator-job",
"methods": ["GET", "POST"],
"actions": { "describe": "POST", "submit": "POST", "read": "GET|POST", "cancel": "POST" },
"methods": [
"GET",
"POST"
],
"actions": {
"describe": "POST",
"submit": "POST",
"read": "GET|POST",
"cancel": "POST"
},
"cacheControl": "no-store",
"configuration": ["BLENDER_BIN", "PYTHON_BIN", "SERVER_OPERATOR_WORKSPACE_ROOT"]
"configuration": [
"BLENDER_BIN",
"PYTHON_BIN",
"SERVER_OPERATOR_WORKSPACE_ROOT"
]
},
"responseHeaders": {
"allResponses": {
@@ -27,27 +54,38 @@
"routes": [
{
"id": "entry-document",
"patterns": ["/", "/index.html"],
"patterns": [
"/",
"/index.html"
],
"cacheControl": "no-cache"
},
{
"id": "runtime-manifest",
"patterns": ["/engine-manifest.json"],
"patterns": [
"/engine-manifest.json"
],
"cacheControl": "no-cache"
},
{
"id": "content-hashed-assets",
"patterns": ["/assets/*"],
"patterns": [
"/assets/*"
],
"cacheControl": "public, max-age=31536000, immutable"
},
{
"id": "stable-engine-assets",
"patterns": ["/vendor/blender/*"],
"patterns": [
"/vendor/blender/*"
],
"cacheControl": "no-cache"
},
{
"id": "fallback",
"patterns": ["*"],
"patterns": [
"*"
],
"cacheControl": "no-cache"
}
]
@@ -73,16 +111,28 @@
".mp4": "video/mp4",
".webm": "video/webm",
".blend": "application/octet-stream",
".nvdb": "application/x-nanovdb"
".nvdb": "application/x-nanovdb",
".vtt": "text/vtt; charset=utf-8"
},
"rangeRequests": {
"extensions": [".wasm", ".blend", ".nvdb"],
"extensions": [
".wasm",
".blend",
".nvdb",
".mp4",
".webm"
],
"unit": "bytes",
"singleRangeOnly": true,
"etag": "strong-sha256",
"satisfiedStatus": 206,
"unsatisfiedStatus": 416,
"requiredSatisfiedHeaders": ["Accept-Ranges", "Content-Range", "Content-Length", "ETag"],
"requiredSatisfiedHeaders": [
"Accept-Ranges",
"Content-Range",
"Content-Length",
"ETag"
],
"ifRangeMismatch": "return-full-200"
}
}

Binary file not shown.

View File

@@ -0,0 +1,9 @@
# This is an Asset Catalog Definition file for Blender.
#
# Empty lines and lines starting with `#` will be ignored.
# The first non-ignored line should be the version indicator.
# Other lines are of the format "UUID:catalog/path/for/assets:simple catalog name"
VERSION 1
96375bf8-9863-4327-8ff9-b72a50135728:砖:砖

BIN
re/猴头.glb Normal file

Binary file not shown.

Binary file not shown.

Binary file not shown.

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 113 KiB

View File

@@ -0,0 +1,52 @@
{
"schemaVersion": 1,
"task": "RE-PBR-LIMITS-001",
"batch": "D-diagnostics",
"status": "PASS",
"generatedAt": "2026-11-12T02:45:33.522245+00:00",
"material": "black_worn_wall_brick",
"resolution": 512,
"camera": "SCENE",
"views": [
{
"mode": "BASE_COLOR",
"path": "tests/golden/RE-PBR-LIMITS-001/diagnostics/diagnostic-base-color-512.png",
"sha256": "ecb7eb53cc87bd921d3b80a98fe4d9b364fe73f1e75b384edaf02f6b4778c773",
"bytes": 140435,
"resolution": 512,
"mean": 93.8943,
"std": 44.8765
},
{
"mode": "NORMAL",
"path": "tests/golden/RE-PBR-LIMITS-001/diagnostics/diagnostic-normal-512.png",
"sha256": "986d33b201f3d53754ba0001d4c5e7890297d96e8c834c4365f89047f31e0dfa",
"bytes": 149281,
"resolution": 512,
"mean": 97.0305,
"std": 46.0513
},
{
"mode": "ROUGHNESS",
"path": "tests/golden/RE-PBR-LIMITS-001/diagnostics/diagnostic-roughness-512.png",
"sha256": "c4e3989db1863209800c46b69e5c8184b5bc38f1941d550b18b793adf20ea97f",
"bytes": 115922,
"resolution": 512,
"mean": 99.7463,
"std": 48.5612
}
],
"exposure": {
"values": [
0,
2
],
"meanDeltaGreaterThan": 1,
"result": "PASS"
},
"notes": [
"Diagnostic views replace only the active object material for the still; the ground stays final.",
"Base Color = MeshBasicMaterial(map); Normal = MeshNormalMaterial(normalMap); Roughness = MeshBasicMaterial(roughnessMap).",
"Teaching exposure is a renderer toneMappingExposure multiplier and does not change material data."
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

View File

@@ -0,0 +1,42 @@
{
"schemaVersion": 1,
"task": "RE-PBR-LIMITS-001",
"batch": "A-displacement",
"status": "PASS",
"generatedAt": "2026-11-12T02:13:19.742774+00:00",
"nativeBinding": {
"usage": "HEIGHT",
"imageId": "image:black_worn_wall_brick_Height.jpg",
"scale": 0.001,
"midlevel": 0.5,
"bias": -0.0005
},
"subdivision": {
"level": 3,
"sourceTriangles": 968,
"renderTriangles": 61952,
"maxTriangles": 100000
},
"renders": {
"1x": {
"path": "tests/golden/RE-PBR-LIMITS-001/displacement/displacement-1x-1024.png",
"sha256": "95105d1e3d3844c815e39a9a41f41761cc798a5cc7f1756f9fd5521f545470ea",
"bytes": 395215,
"mean": 93.9552,
"std": 44.7742
},
"100x": {
"path": "tests/golden/RE-PBR-LIMITS-001/displacement/displacement-100x-1024.png",
"sha256": "f5b5e0872de3ad9cb34479a867c892bcd2e69c8a06eab57bd1fdf8dd69f905d5",
"bytes": 395465,
"mean": 93.8682,
"std": 44.8044
}
},
"meanDelta": 0.087,
"notes": [
"HEIGHT binding comes from the native reader of the real RE library blend.",
"renderStill subdivides the 968-triangle monkey to 61,952 triangles at level 3 and applies MeshPhysicalMaterial.displacementMap.",
"1x preserves the source Scale=0.001; 100x is a renderer-only teaching multiplier and does not write the material."
]
}

View File

@@ -0,0 +1,84 @@
{
"schemaVersion": 1,
"task": "RE-PBR-LIMITS-001",
"batch": "0-gpu-enable",
"status": "PASS",
"generatedAt": "2026-11-12T00:58:45.890906+00:00",
"browser": {
"executable": "/usr/bin/google-chrome",
"headed": true,
"display": ":1"
},
"launch": {
"gpuMode": "hardware",
"args": [
"--no-sandbox",
"--use-gl=angle",
"--use-angle=gl",
"--ignore-gpu-blocklist",
"--enable-gpu-rasterization",
"--enable-zero-copy",
"--enable-unsafe-webgpu"
]
},
"webgl": {
"vendor": "Google Inc. (NVIDIA Corporation)",
"renderer": "ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 4060 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0)",
"version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)"
},
"webgpu": {
"adapter": "present",
"info": {}
},
"e2e": {
"spec": "web/tests/e2e/re-pbr-render-workflow.spec.ts",
"grep": "assign six materials",
"result": "1 passed",
"durationSeconds": 27.0,
"swiftshaderReferenceDurationSeconds": 35.8
},
"render": {
"material": "black_worn_wall_brick",
"resolution": 1024,
"camera": "SCENE",
"hardware": {
"path": "tests/golden/RE-PBR-LIMITS-001/gpu/hardware-black_worn_wall_brick-1024.png",
"sha256": "62d9aaf2b598f7622029a66c483cdcd6388bb47e4d2b4bb43476739742a1ce67",
"bytes": 412605
},
"swiftshader": {
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_worn_wall_brick-1024-scene.png",
"sha256": "16f7d4ef3ae678c7d787c1da470d0bc7363e0ed160b7f7e42d4b3a21361ce0b5",
"bytes": 399540
},
"meanStd": {
"hardware": {
"mean": 95.277,
"std": 44.758
},
"swiftshader": {
"mean": 95.332,
"std": 44.724
}
},
"note": "Renderer confirms NVIDIA RTX 4060 via ANGLE; SHA differs only by raster timing, mean/std within 0.06."
},
"notes": [
"Playwright config now selects hardware when GPU_MODE=hardware or DISPLAY is present; SwiftShader is explicit fallback.",
"Capture scripts accept --gpu hardware|swiftshader and probe WEBGL_debug_renderer_info before capture; hardware lane fails closed on SwiftShader/llvmpipe."
],
"hardwareCapture": {
"status": "PASS",
"renders": 20,
"steps": 15,
"uniqueSha": 20,
"report": "tests/golden/RE-PBR-RENDER-001/report.json",
"reportSha256": "40c5a758df05c566d24c9973862c6bb6fa01e74dc6bbc772f4fe5a9623be4481",
"webgl": {
"vendor": "Google Inc. (NVIDIA Corporation)",
"renderer": "ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 4060 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0)",
"version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)"
},
"note": "20/20 unique; SCENE vs VIEWPORT now differ because the capture waits for the new result src / idle button. ready:0:0 on reopen remains for Phase C."
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 403 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 387 KiB

View File

@@ -0,0 +1,38 @@
{
"schemaVersion": 1,
"task": "RE-PBR-LIMITS-001",
"batch": "E-hq-2048",
"status": "PASS",
"generatedAt": "2026-11-12T02:56:52.562806+00:00",
"library": {
"id": "library:re-pbr-hq-v1",
"path": "web/app/public/assets/re-pbr-hq/re-pbr-library.blend",
"sha256": "2875cf7b3715fb0658582e2c6219300b72681e477079506e1b41a8551cd62efe",
"bytes": 10846909,
"textures": "original 2048 packed JPEG"
},
"material": "black_worn_wall_brick_HQ",
"resolution": 1024,
"camera": "SCENE",
"render": {
"path": "tests/golden/RE-PBR-LIMITS-001/hq/hq-2048-1024.png",
"sha256": "6ed6a69b7eb5a130037da32303b4f3d6a64bb940953934d3640070d548afca06",
"bytes": 396748,
"mean": 93.9619,
"std": 44.7738
},
"budget": {
"gpuDecodedBytes": 67633152,
"payloadBytes": 2625921,
"loaded": 5,
"rejected": 0,
"underWebGL2Limit": true,
"webgl2DecodedLimitBytes": 536870912
},
"mode": "lazy on-demand via PbrLessonPanel; standard library remains default",
"notes": [
"HQ material names use _HQ suffix to avoid conflict with standard library.",
"Native reader now reads packed JPEG dimensions; Height images verified 2048x2048.",
"Budget is base-level decoded; mipmaps can add ~1/3."
]
}

View File

@@ -0,0 +1,102 @@
{
"schemaVersion": 1,
"task": "RE-PBR-LIMITS-001",
"status": "IMPLEMENTED_NOT_USER_ACCEPTED",
"generatedAt": "2026-11-12T03:10:25.986813+00:00",
"gpu": {
"mode": "hardware",
"renderer": "ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 4060 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0)"
},
"evidence": {
"displacement": {
"path": "tests/golden/RE-PBR-LIMITS-001/displacement/report.json",
"sha256": "7cb8f1c699f869e197aa8995a71dd2c002388080aa33aaecc3eb467027f43073",
"bytes": 1361
},
"diagnostics": {
"path": "tests/golden/RE-PBR-LIMITS-001/diagnostics/report.json",
"sha256": "0931566cf9c2b0575659f6aba0a22f27216d43e2edc14e67536eff7a5689ee8c",
"bytes": 1633
},
"hq": {
"path": "tests/golden/RE-PBR-LIMITS-001/hq/report.json",
"sha256": "034188535227a629e9da69b700edd287f102629c058041ce6de159194c7c6154",
"bytes": 1252
},
"gpuEnable": {
"path": "tests/golden/RE-PBR-LIMITS-001/gpu/batch0-gpu-enable.json",
"sha256": "f74d4da0e4858cff4fe1f5602face87e313f8b00dcde90fa5e4ba03969cbe5a9",
"bytes": 2839
},
"renders": {
"path": "tests/golden/RE-PBR-RENDER-001/report.json",
"sha256": "1478eae327295857a12d757e3d5f5f06c2d6f0f9adb89c411a4c752d10cc62c4",
"bytes": 17168
},
"lesson": {
"path": "web/app/public/tutorial/pbr/render-lesson.html",
"sha256": "fb2aaf90c7e08c30273cf2ddee6535b75ccf628beb9ba07c2c85251dd893473d",
"bytes": 27485
},
"hqLibrary": {
"path": "web/app/public/assets/re-pbr-hq/re-pbr-library.blend",
"sha256": "2875cf7b3715fb0658582e2c6219300b72681e477079506e1b41a8551cd62efe",
"bytes": 10846909
}
},
"summary": {
"displacement": {
"nativeUsage": "HEIGHT",
"scale": 0.001,
"midlevel": 0.5,
"sourceTriangles": 968,
"renderTriangles": 61952,
"meanDelta": 0.087
},
"diagnostics": {
"views": [
"BASE_COLOR",
"NORMAL",
"ROUGHNESS"
],
"exposureEv": [
0,
2
]
},
"hq": {
"libraryBytes": 10846909,
"gpuDecodedBytes": 67633152,
"payloadBytes": 2625921,
"loaded": 5,
"rejected": 0
},
"camera": {
"presets": [
"SCENE_CAMERA",
"COMPARISON"
],
"uniqueRenders": 20
},
"reopen": {
"textureStatus": "ready:5:0:",
"loaded": 5
},
"crossBrowser": "policy-excluded: Chromium-only"
},
"checks": {
"typecheck": "PASS",
"unitShaderCompiler": "13/13",
"mainE2E": "PASS 3.6m",
"fastE2E": "5/5",
"pbrRegressions": "3/3",
"checkRePbrLesson": "PASS",
"checkRePbrLibraryHQ": "PASS",
"checkRePbrLimits": "PASS"
},
"notes": [
"Desktop EEVEE reference not run; displacement proof is Web 100x.",
"HQ mipmaps can add ~1/3 over base-level budget.",
"User acceptance pending."
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 315 KiB

View File

@@ -0,0 +1,79 @@
{
"schemaVersion": 1,
"task": "RE-PBR-RENDER-001",
"status": "IMPLEMENTED_NOT_USER_ACCEPTED",
"renderer": "WEB_WEBGL2",
"headed": true,
"browser": "151.0.7922.137",
"display": ":1",
"generatedAt": "2026-11-11T14:46:37.441Z",
"source": {
"catalog": {
"path": "re/5000PBR\u7cbe\u7b80\u7248/5000+ PBR catalog.blend",
"sha256": "54942f057f913c9923752ba0d4ddcfa6d1f77e98c5e812b83409fb128968c125"
},
"glb": {
"path": "tests/files/web/pbr-render-lesson/monkey-head.glb",
"sha256": "60267d607b63ceb5bfc972342a9ff17310d6978ff632f240ecb963c4d5a7e003"
},
"studio": {
"path": "web/app/public/tutorial/pbr/studio.blend",
"sha256": "621ffacc0eaea9f9efd087849de048804dd45cd13f94494b1f1a91328143feed"
}
},
"derived": {
"library": {
"path": "web/app/public/assets/re-pbr/re-pbr-library.blend",
"sha256": "d67f5a058c154626dfc7171c5bc7fc254b8ee183fea8740261b9711b3309d782",
"libraryId": "library:re-pbr-v1"
},
"manifest": {
"path": "web/app/public/assets/re-pbr/manifest.json",
"sha256": "2c68527e7ecfa5107aff2161a642c49135ec212e78532cf49682563ac0f3fdd0"
},
"materials": 6,
"previews": 6
},
"rendererArtifact": {
"web_engine.wasm": "909290259e2911af49f1d6f308e1e05e01f62aa1841847b878bcd3191b98c24c"
},
"lesson": {
"html": {
"path": "web/app/public/tutorial/pbr/render-lesson.html",
"sha256": "d7a1545c225c60f30a7ecb190bcbf3cf89b7e2fdaaaae65dde58db67e476490f"
},
"css": {
"path": "web/app/public/tutorial/pbr/render-lesson.css",
"sha256": "72118b8b041fb457c7f8cdc26c5d1eb3be3493a3fed46a62d893a26b9394ee76"
},
"renders": 20,
"steps": 15
},
"checks": {
"re-pbr-library": "PASS",
"shader-compiler-unit": "13/13",
"typecheck": "PASS",
"build": "PASS",
"re-pbr-render-workflow-e2e-headed": "2/2",
"pbr-regressions-headed": "3/3",
"re-pbr-lesson-check": "PASS",
"lesson-page-headed": "35 images decoded; no HTTP failures"
},
"report": {
"path": "tests/golden/RE-PBR-RENDER-001/report.json",
"sha256": "cf9ad91274e0709e767220300754e1a4e3bb2a1cf82c39c86fe3bb8c81073aee"
},
"notes": [
"Six RE materials include the formerly unmarked Black_Anti_Slip_grip; source .blend untouched.",
"Displacement is ignored by the Web renderer; Base Color/Normal/Roughness are rendered.",
"Viewport and scene camera images can be identical because the default viewport is aligned to the studio camera."
],
"savedProject": {
"path": "tests/golden/RE-PBR-RENDER-001/re-pbr-lesson-saved.blend",
"sha256": "8d41376ae030e1d2aad43a27dd511e446d141fc8122347af3d5cac422ede3ebb"
},
"lessonPageScreenshot": {
"path": "tests/golden/RE-PBR-RENDER-001/lesson-page.png",
"sha256": "e5d0359028241d873b50f66e61d5a98fc1e17e75e4fb81d8854bd17d762dbf38"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 326 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 397 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 540 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 339 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 426 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 369 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 458 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 131 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 398 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 521 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 139 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 364 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 469 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 518 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 135 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 386 KiB

View File

@@ -0,0 +1,411 @@
{
"schemaVersion": 1,
"task": "RE-PBR-RENDER-001",
"renderer": "WEB_WEBGL2",
"headed": true,
"gpuMode": "hardware",
"browser": "151.0.7922.137",
"chromePath": "/usr/bin/google-chrome",
"display": ":1",
"generatedAt": "2026-11-12T13:24:47.364Z",
"studioSha256": "621ffacc0eaea9f9efd087849de048804dd45cd13f94494b1f1a91328143feed",
"glbSha256": "60267d607b63ceb5bfc972342a9ff17310d6978ff632f240ecb963c4d5a7e003",
"materials": [
"Black_Anti_Slip_grip",
"Blackish_Brick_Wall_016",
"black_randome_tile",
"black_shinning_tile",
"black_tiles_wall",
"black_worn_wall_brick"
],
"renders": [
{
"name": "baseline-unassigned-1024-scene",
"material": null,
"resolution": 1024,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/baseline-unassigned-1024-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/baseline-unassigned-1024-scene.jpg",
"sha256": "4084f64fcd9cb981e241ddc120c5a0f6e256d1c7896d525579a6e3be60ffc265",
"byteLength": 333634,
"mean": 94.5402,
"std": 45.4315,
"textureStatus": "ready:1:0:",
"textureAssets": 1,
"compileStatus": "BASELINE"
},
{
"name": "material-Black_Anti_Slip_grip-1024-scene",
"material": "Black_Anti_Slip_grip-1024-scene",
"resolution": 1024,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-Black_Anti_Slip_grip-1024-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-Black_Anti_Slip_grip-1024-scene.jpg",
"sha256": "dcc7194607f100bea6f8c7ae376cda991e208e0341e51f8f42edb8ea79026eeb",
"byteLength": 406827,
"mean": 94.6004,
"std": 45.5247,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-Black_Anti_Slip_grip-1024-viewport",
"material": "Black_Anti_Slip_grip-1024-viewport",
"resolution": 1024,
"camera": "VIEWPORT",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-Black_Anti_Slip_grip-1024-viewport.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-Black_Anti_Slip_grip-1024-viewport.jpg",
"sha256": "a2906859a592b3fe550db7cac69f6d968f8cccb92569dda2edcc50ff7670d760",
"byteLength": 552825,
"mean": 68.1723,
"std": 19.4146,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-Black_Anti_Slip_grip-512-scene",
"material": "Black_Anti_Slip_grip-512-scene",
"resolution": 512,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-Black_Anti_Slip_grip-512-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-Black_Anti_Slip_grip-512-scene.jpg",
"sha256": "d97dbd711526016c8f7e4abc6ea565ae1243a8f982bfce9eac6c3cfd4b256273",
"byteLength": 138648,
"mean": 94.5944,
"std": 45.4802,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-Blackish_Brick_Wall_016-1024-scene",
"material": "Blackish_Brick_Wall_016-1024-scene",
"resolution": 1024,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-Blackish_Brick_Wall_016-1024-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-Blackish_Brick_Wall_016-1024-scene.jpg",
"sha256": "3f57bbdf0db50a9ef31e730e6e868e138917143fbb5f08c2d78fc10fd2d89d57",
"byteLength": 347506,
"mean": 93.4853,
"std": 45.7205,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-Blackish_Brick_Wall_016-1024-viewport",
"material": "Blackish_Brick_Wall_016-1024-viewport",
"resolution": 1024,
"camera": "VIEWPORT",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-Blackish_Brick_Wall_016-1024-viewport.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-Blackish_Brick_Wall_016-1024-viewport.jpg",
"sha256": "dc590cdd28d7a04f773a03fa8f5977adc22fd4db96df4b23196356380f322bd0",
"byteLength": 436440,
"mean": 66.7569,
"std": 17.6259,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-Blackish_Brick_Wall_016-512-scene",
"material": "Blackish_Brick_Wall_016-512-scene",
"resolution": 512,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-Blackish_Brick_Wall_016-512-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-Blackish_Brick_Wall_016-512-scene.jpg",
"sha256": "8b6a49a0c3141c9ac1c904792f47f8b4492348992db481550f2067c6c8a128c9",
"byteLength": 125970,
"mean": 93.4702,
"std": 45.7063,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_randome_tile-1024-scene",
"material": "black_randome_tile-1024-scene",
"resolution": 1024,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_randome_tile-1024-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_randome_tile-1024-scene.jpg",
"sha256": "a54f9caf195b9726e6792dc3fa7e92d59c9dc485f3c3bc7e65d94cc62a900253",
"byteLength": 377709,
"mean": 93.6921,
"std": 45.541,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_randome_tile-1024-viewport",
"material": "black_randome_tile-1024-viewport",
"resolution": 1024,
"camera": "VIEWPORT",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_randome_tile-1024-viewport.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_randome_tile-1024-viewport.jpg",
"sha256": "697e37d18a2b7575b1b2a05a0b931c17565ac48c3869358dac1c509a21491457",
"byteLength": 469187,
"mean": 67.1074,
"std": 17.5655,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_randome_tile-512-scene",
"material": "black_randome_tile-512-scene",
"resolution": 512,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_randome_tile-512-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_randome_tile-512-scene.jpg",
"sha256": "ee40d1e815c7d60d8f96c5a0c3edeefdb3245a25c76c35ec7193e0d6bcc182e4",
"byteLength": 134145,
"mean": 93.6227,
"std": 45.5507,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_shinning_tile-1024-scene",
"material": "black_shinning_tile-1024-scene",
"resolution": 1024,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_shinning_tile-1024-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_shinning_tile-1024-scene.jpg",
"sha256": "4252afbc06e936d552c40bd20c1de4fbf74902df5521e574b3e49f3633af045d",
"byteLength": 407550,
"mean": 94.6918,
"std": 45.7203,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_shinning_tile-1024-viewport",
"material": "black_shinning_tile-1024-viewport",
"resolution": 1024,
"camera": "VIEWPORT",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_shinning_tile-1024-viewport.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_shinning_tile-1024-viewport.jpg",
"sha256": "3996b7d704dceaa4215a0126db4ee6387ef00c48b67932e86c75983e22dd5c07",
"byteLength": 533567,
"mean": 68.3535,
"std": 20.5096,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_shinning_tile-512-scene",
"material": "black_shinning_tile-512-scene",
"resolution": 512,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_shinning_tile-512-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_shinning_tile-512-scene.jpg",
"sha256": "2a4bd3aa9aa24998d7d236b1450a95fc93ccaae8d34bc0ee2fe64620d8bf4c79",
"byteLength": 142153,
"mean": 94.6237,
"std": 45.5561,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_tiles_wall-1024-scene",
"material": "black_tiles_wall-1024-scene",
"resolution": 1024,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_tiles_wall-1024-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_tiles_wall-1024-scene.jpg",
"sha256": "b2e037c2151bd53eab519f59495590c09588c0b89573b39c7b802435f91c5171",
"byteLength": 372178,
"mean": 94.207,
"std": 45.2282,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_tiles_wall-1024-viewport",
"material": "black_tiles_wall-1024-viewport",
"resolution": 1024,
"camera": "VIEWPORT",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_tiles_wall-1024-viewport.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_tiles_wall-1024-viewport.jpg",
"sha256": "0b1f61d4c9db1cb8b142af3b7bfa0bbca75884aba3f4130dec0e2e22269d3440",
"byteLength": 479935,
"mean": 68.0221,
"std": 17.3012,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_tiles_wall-512-scene",
"material": "black_tiles_wall-512-scene",
"resolution": 512,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_tiles_wall-512-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_tiles_wall-512-scene.jpg",
"sha256": "7bff874cb5992052880cbb947394d026589600fa59cc9b0405458edd605cfd9d",
"byteLength": 131660,
"mean": 94.1789,
"std": 45.2036,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_worn_wall_brick-1024-scene",
"material": "black_worn_wall_brick-1024-scene",
"resolution": 1024,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_worn_wall_brick-1024-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_worn_wall_brick-1024-scene.jpg",
"sha256": "95105d1e3d3844c815e39a9a41f41761cc798a5cc7f1756f9fd5521f545470ea",
"byteLength": 395215,
"mean": 94.3206,
"std": 45.1801,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_worn_wall_brick-1024-viewport",
"material": "black_worn_wall_brick-1024-viewport",
"resolution": 1024,
"camera": "VIEWPORT",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_worn_wall_brick-1024-viewport.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_worn_wall_brick-1024-viewport.jpg",
"sha256": "1ac2d53d563f77a81e02d81c897bd1866a948e5451f9a89070d6ebbd00014461",
"byteLength": 530442,
"mean": 68.5761,
"std": 17.6293,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_worn_wall_brick-512-scene",
"material": "black_worn_wall_brick-512-scene",
"resolution": 512,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_worn_wall_brick-512-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_worn_wall_brick-512-scene.jpg",
"sha256": "0e175dc7ee9432e08496ad2ff57e96457646e08344039f7d6ee113c5f4b5376f",
"byteLength": 138231,
"mean": 94.2668,
"std": 45.1655,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "COMPILED"
},
{
"name": "material-black_worn_wall_brick-reopen-1024-scene",
"material": "black_worn_wall_brick-reopen-1024-scene",
"resolution": 1024,
"camera": "SCENE",
"path": "tests/golden/RE-PBR-RENDER-001/renders/material-black_worn_wall_brick-reopen-1024-scene.png",
"jpg": "web/app/public/tutorial/pbr/lesson/renders/material-black_worn_wall_brick-reopen-1024-scene.jpg",
"sha256": "d2c9f5eb4d3e93c24c7dd3d0d7e2c8e7eacee99698fca1d996ecacbdbbff147e",
"byteLength": 395066,
"mean": 94.3106,
"std": 45.1856,
"textureStatus": "ready:5:0:",
"textureAssets": 5,
"compileStatus": "REOPENED"
}
],
"steps": [
{
"name": "01-open-studio",
"path": "web/app/public/tutorial/pbr/lesson/steps/01-open-studio.jpg",
"sha256": "cbedb2be5fd6a9defebdddc23a2bbeb8410ea848416b9f677617e8c7b16af58d"
},
{
"name": "02-import-glb",
"path": "web/app/public/tutorial/pbr/lesson/steps/02-import-glb.jpg",
"sha256": "3a060c51daa6a61a98e07a80e8455373f8b7bebbfaccb7660215c47a41f173a2"
},
{
"name": "03-asset-library-Black_Anti_Slip_grip",
"path": "web/app/public/tutorial/pbr/lesson/steps/03-asset-library-Black_Anti_Slip_grip.jpg",
"sha256": "afdca32dc63617c82ca199c1fdcab51998dc17618d2098ab0f1bd412eb005eef"
},
{
"name": "04-assign-Black_Anti_Slip_grip",
"path": "web/app/public/tutorial/pbr/lesson/steps/04-assign-Black_Anti_Slip_grip.jpg",
"sha256": "5c3501511c0aae269d40ac96f5f3375668c7e8304cbabfb7d757cd64ccd62faa"
},
{
"name": "03-asset-library-Blackish_Brick_Wall_016",
"path": "web/app/public/tutorial/pbr/lesson/steps/03-asset-library-Blackish_Brick_Wall_016.jpg",
"sha256": "afdca32dc63617c82ca199c1fdcab51998dc17618d2098ab0f1bd412eb005eef"
},
{
"name": "04-assign-Blackish_Brick_Wall_016",
"path": "web/app/public/tutorial/pbr/lesson/steps/04-assign-Blackish_Brick_Wall_016.jpg",
"sha256": "fbd8a368e86bb19cce387312a23eeb5c503abc221475cda4e6feb2597a76d8d0"
},
{
"name": "03-asset-library-black_randome_tile",
"path": "web/app/public/tutorial/pbr/lesson/steps/03-asset-library-black_randome_tile.jpg",
"sha256": "afdca32dc63617c82ca199c1fdcab51998dc17618d2098ab0f1bd412eb005eef"
},
{
"name": "04-assign-black_randome_tile",
"path": "web/app/public/tutorial/pbr/lesson/steps/04-assign-black_randome_tile.jpg",
"sha256": "9087fb6b6af7ac3e6291174a6df245c9ea49abf049444d0152e1396d4ad775ba"
},
{
"name": "03-asset-library-black_shinning_tile",
"path": "web/app/public/tutorial/pbr/lesson/steps/03-asset-library-black_shinning_tile.jpg",
"sha256": "afdca32dc63617c82ca199c1fdcab51998dc17618d2098ab0f1bd412eb005eef"
},
{
"name": "04-assign-black_shinning_tile",
"path": "web/app/public/tutorial/pbr/lesson/steps/04-assign-black_shinning_tile.jpg",
"sha256": "cf3fbdaa66fd612f8d26faa6015f1abc208a64d06bdedd77bea9e4ca1d23b383"
},
{
"name": "03-asset-library-black_tiles_wall",
"path": "web/app/public/tutorial/pbr/lesson/steps/03-asset-library-black_tiles_wall.jpg",
"sha256": "afdca32dc63617c82ca199c1fdcab51998dc17618d2098ab0f1bd412eb005eef"
},
{
"name": "04-assign-black_tiles_wall",
"path": "web/app/public/tutorial/pbr/lesson/steps/04-assign-black_tiles_wall.jpg",
"sha256": "9ee41dd525128d31ed46664d9edfcf3de150db65c47522002cd307605af7681e"
},
{
"name": "03-asset-library-black_worn_wall_brick",
"path": "web/app/public/tutorial/pbr/lesson/steps/03-asset-library-black_worn_wall_brick.jpg",
"sha256": "afdca32dc63617c82ca199c1fdcab51998dc17618d2098ab0f1bd412eb005eef"
},
{
"name": "04-assign-black_worn_wall_brick",
"path": "web/app/public/tutorial/pbr/lesson/steps/04-assign-black_worn_wall_brick.jpg",
"sha256": "847032ffa0bbed0836478b0468504b0c48709159240351c4e21bffef9198e86f"
},
{
"name": "05-reopen",
"path": "web/app/public/tutorial/pbr/lesson/steps/05-reopen.jpg",
"sha256": "f09769ce96480560b499ad616ab868f3a8ddeb97a4f7998bb9f80c451da6b4e7"
}
],
"errors": [],
"webgl": {
"vendor": "Google Inc. (NVIDIA Corporation)",
"renderer": "ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 4060 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0)",
"version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)"
},
"webgpu": {
"info": {}
},
"status": "PASS",
"consoleErrors": []
}

View File

@@ -0,0 +1,109 @@
{
"voice": "zh-CN-XiaoxiaoNeural",
"chapters": [
{
"id": "intro",
"title": "开场:会得到什么",
"narration": "这一课我们从灰色猴头开始。你会打开摄影棚,导入只有一个猴头网格的 GLB 文件,从 RE 五零零零 PBR 资产库分配六种材质,最后在浏览器里用显卡渲染、下载 PNG、保存工程并重新打开。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/intro.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/intro.wav",
"durationMs": 19608
},
{
"id": "open-studio",
"title": "载入摄影棚",
"narration": "先打开 Web Blender 的 Rendering 工作区。在 PBR 渲染练习面板里点击载入练习摄影棚。这个场景已经准备好相机、三点面光、太阳光、世界环境和地面。等待状态栏显示 Engine SceneIR,并且对象数是六。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/open-studio.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/open-studio.wav",
"durationMs": 19992
},
{
"id": "import-glb",
"title": "导入猴头 GLB",
"narration": "接下来点击导入猴头 GLB,选择课程目录里的 monkey head 点 GLB。导入会追加到当前场景,不会替换摄影棚。看到对象数从六变成七,活动对象是 Suzanne 就对了。此时猴头没有材质,所以是灰色的,这是正常的基线。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/import-glb.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/import-glb.wav",
"durationMs": 21216
},
{
"id": "asset-library",
"title": "认识 Shading 与 RE 资产库",
"narration": "切换到 Shading 工作区。左下角是 PBR 资产库,现在有二十个资产。用资产来源下拉框选择 RE 五零零零 PBR,只留下六张材质卡片。单击卡片只会选中,双击才会把材质分配出去。缩略图加载完成后就可以继续。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/asset-library.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/asset-library.wav",
"durationMs": 22176
},
{
"id": "assign-materials",
"title": "分配六种材质",
"narration": "回到 Rendering 工作区。在 PBR 渲染练习面板下方,新加了 RE 五零零零 PBR 材质选择器,它显示完整材质名和缩略图,不受左侧窄栏影响。依次双击六种材质:防滑颗粒、砖墙、随机瓷砖、光亮瓷砖、规则砖墙和做旧砖墙。每次等待材质槽更新,并确认材质球显示 COMPILED。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/assign-materials.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/assign-materials.wav",
"durationMs": 28608
},
{
"id": "render-settings",
"title": "设置渲染",
"narration": "现在设置渲染。分辨率选一零二四,相机选场景相机,视图保持最终材质,教学曝光保持零,位移强度保持一倍,视角预设选场景相机对齐。这样得到的就是固定摄影棚下的标准成片。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/render-settings.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/render-settings.wav",
"durationMs": 18648
},
{
"id": "render-download",
"title": "渲染与下载 PNG",
"narration": "点击渲染 PBR。面板会先等待纹理和环境贴图就绪,然后输出一张一零二四乘一零二四的 WebGL2 成片。结果出现后,点击下载 PNG,浏览器会保存 pbr 一零二四点 PNG。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/render-download.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/render-download.wav",
"durationMs": 18312
},
{
"id": "save-reopen",
"title": "保存与重开",
"narration": "点击顶部的保存项目,下载点 blend 工程。然后再次导入刚才保存的工程;如果询问是否保存当前修改,选择不保存并继续。重新打开后对象数仍是七,材质槽仍然保留做旧砖墙,再渲染一次确认贴图没有丢失。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/save-reopen.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/save-reopen.wav",
"durationMs": 20568
},
{
"id": "diagnostics",
"title": "诊断视图与教学曝光",
"narration": "深色材质只看整体亮度容易误判。把视图切到 Base Color,可以看到基础色贴图;切到 Normal,可以看到法线和砖缝;切到 Roughness,可以看到粗糙度。教学曝光可以加一档或两档 EV,只改变渲染曝光,不改变材质本身。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/diagnostics.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/diagnostics.wav",
"durationMs": 20880
},
{
"id": "displacement",
"title": "位移与细分",
"narration": "Height 贴图现在会真正参与几何位移。位移强度一倍保留源材质零点零零一的 Scale,画面变化很细;切到一百倍只是渲染时的教学放大,不写回材质。静帧会把九百六十八个三角形细分到六万一千九百五十二个,再执行位移。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/displacement.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/displacement.wav",
"durationMs": 21048
},
{
"id": "camera",
"title": "对比视角",
"narration": "视角预设默认与场景相机精确对齐,所以场景和视口可能看起来一样。选择对比四十五度后,当前视口会以主体为中心偏转三十度,得到明显不同的构图;主视口导航不受影响。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/camera.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/camera.wav",
"durationMs": 17520
},
{
"id": "hq-2048",
"title": "高清 2048 贴图",
"narration": "如果需要原始分辨率,点击加载高清贴图二零四八。素材会按需合并,材质名带 HQ 后缀。切换到高清来源,双击分配做旧砖墙 HQ,再渲染一次。五个纹理全部加载,显存预算约六十四点五兆,低于五百一十二兆上限。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/hq-2048.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/hq-2048.wav",
"durationMs": 22008
},
{
"id": "summary",
"title": "总结与教案",
"narration": "到这里,你已经完成从灰色猴头到六种 PBR 材质的完整流程:打开场景、导入 GLB、分配材质、渲染下载、保存重开,以及诊断、位移、对比相机和高清贴图。更多截图和文字步骤在渲染图文教案里,地址是 tutorial pbr render lesson 点 HTML。",
"mp3": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/summary.mp3",
"wav": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration/summary.wav",
"durationMs": 24936
}
]
}

View File

@@ -0,0 +1,111 @@
{
"schemaVersion": 1,
"status": "PASS",
"gpu": {
"vendor": "Google Inc. (NVIDIA Corporation)",
"renderer": "ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 4060 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0)",
"version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)"
},
"gpuMode": "hardware",
"smoke": false,
"durationMs": 669467,
"chapters": [
{
"id": "intro",
"title": "开场:会得到什么",
"startMs": 3630,
"endMs": 39087,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/intro.jpg"
},
{
"id": "open-studio",
"title": "载入摄影棚",
"startMs": 39088,
"endMs": 74925,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/open-studio.jpg"
},
{
"id": "import-glb",
"title": "导入猴头 GLB",
"startMs": 74925,
"endMs": 111990,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/import-glb.jpg"
},
{
"id": "asset-library",
"title": "认识 Shading 与 RE 资产库",
"startMs": 111991,
"endMs": 150022,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/asset-library.jpg"
},
{
"id": "assign-materials",
"title": "分配六种材质",
"startMs": 150022,
"endMs": 283150,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/assign-materials.jpg"
},
{
"id": "render-settings",
"title": "设置渲染",
"startMs": 283150,
"endMs": 317656,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/render-settings.jpg"
},
{
"id": "render-download",
"title": "渲染与下载 PNG",
"startMs": 317656,
"endMs": 378905,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/render-download.jpg"
},
{
"id": "save-reopen",
"title": "保存与重开",
"startMs": 378906,
"endMs": 416940,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/save-reopen.jpg"
},
{
"id": "diagnostics",
"title": "诊断视图与教学曝光",
"startMs": 416941,
"endMs": 492011,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/diagnostics.jpg"
},
{
"id": "displacement",
"title": "位移与细分",
"startMs": 492012,
"endMs": 528899,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/displacement.jpg"
},
{
"id": "camera",
"title": "对比视角",
"startMs": 528900,
"endMs": 562279,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/camera.jpg"
},
{
"id": "hq-2048",
"title": "高清 2048 贴图",
"startMs": 562279,
"endMs": 625129,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/hq-2048.jpg"
},
{
"id": "summary",
"title": "总结与教案",
"startMs": 625130,
"endMs": 665905,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/summary.jpg"
}
],
"errors": [],
"screencast": {
"path": "tests/golden/RE-PBR-VIDEO-001/screencast.webm",
"sha256": "e1ccb9497c851b2a0a3c2fe677b00587e68399340ff956a5bbe54a6bce942778",
"bytes": 42699067
}
}

View File

@@ -0,0 +1,111 @@
{
"schemaVersion": 1,
"status": "PASS",
"gpu": {
"vendor": "Google Inc. (NVIDIA Corporation)",
"renderer": "ANGLE (NVIDIA Corporation, NVIDIA GeForce RTX 4060 Laptop GPU/PCIe/SSE2, OpenGL 4.5.0)",
"version": "WebGL 2.0 (OpenGL ES 3.0 Chromium)"
},
"gpuMode": "hardware",
"smoke": false,
"durationMs": 669467,
"chapters": [
{
"id": "intro",
"title": "开场:会得到什么",
"startMs": 3630,
"endMs": 39087,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/intro.jpg"
},
{
"id": "open-studio",
"title": "载入摄影棚",
"startMs": 39088,
"endMs": 74925,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/open-studio.jpg"
},
{
"id": "import-glb",
"title": "导入猴头 GLB",
"startMs": 74925,
"endMs": 111990,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/import-glb.jpg"
},
{
"id": "asset-library",
"title": "认识 Shading 与 RE 资产库",
"startMs": 111991,
"endMs": 150022,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/asset-library.jpg"
},
{
"id": "assign-materials",
"title": "分配六种材质",
"startMs": 150022,
"endMs": 283150,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/assign-materials.jpg"
},
{
"id": "render-settings",
"title": "设置渲染",
"startMs": 283150,
"endMs": 317656,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/render-settings.jpg"
},
{
"id": "render-download",
"title": "渲染与下载 PNG",
"startMs": 317656,
"endMs": 378905,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/render-download.jpg"
},
{
"id": "save-reopen",
"title": "保存与重开",
"startMs": 378906,
"endMs": 416940,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/save-reopen.jpg"
},
{
"id": "diagnostics",
"title": "诊断视图与教学曝光",
"startMs": 416941,
"endMs": 492011,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/diagnostics.jpg"
},
{
"id": "displacement",
"title": "位移与细分",
"startMs": 492012,
"endMs": 528899,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/displacement.jpg"
},
{
"id": "camera",
"title": "对比视角",
"startMs": 528900,
"endMs": 562279,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/camera.jpg"
},
{
"id": "hq-2048",
"title": "高清 2048 贴图",
"startMs": 562279,
"endMs": 625129,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/hq-2048.jpg"
},
{
"id": "summary",
"title": "总结与教案",
"startMs": 625130,
"endMs": 665905,
"screenshot": "web/app/public/tutorial/pbr/lesson/video-steps/summary.jpg"
}
],
"errors": [],
"screencast": {
"path": "tests/golden/RE-PBR-VIDEO-001/screencast.webm",
"sha256": "e1ccb9497c851b2a0a3c2fe677b00587e68399340ff956a5bbe54a6bce942778",
"bytes": 42699067
}
}

View File

@@ -0,0 +1,22 @@
{
"programs": [
],
"streams": [
{
"codec_name": "h264",
"codec_type": "video",
"width": 1280,
"height": 720
},
{
"codec_name": "aac",
"codec_type": "audio"
}
],
"format": {
"duration": "667.040000",
"size": "13706823"
}
}

View File

@@ -0,0 +1,117 @@
{
"schemaVersion": 1,
"status": "PASS",
"voice": "zh-CN-XiaoxiaoNeural",
"video": {
"path": "/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4",
"sha256": "6a5b3b51c52945d0594a984ad6b5ff2e557ec319c574679673bd8d6d2a8a2916",
"bytes": 13706823
},
"narration": {
"path": "/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001/narration.wav",
"sha256": "adc2658938ad48b278aa434cb9635343491f7241f4632b4af6c9baa9271b9220",
"bytes": 64268876
},
"vtt": {
"path": "/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video/re-pbr-lesson.zh-Hans.vtt",
"sha256": "4199af3a9c1ad501ebd7832e43693d43d2bbd30a646a8285343eefc08a237aa3",
"cues": 13
},
"poster": {
"path": "/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video/poster.jpg",
"sha256": "6461410e032de2c92f3f898873d9dca9834f2538e65cd0e4b2d8f3c443d0f444"
},
"chapters": [
{
"id": "intro",
"title": "开场:会得到什么",
"startMs": 3630,
"endMs": 23238,
"narration": "这一课我们从灰色猴头开始。你会打开摄影棚,导入只有一个猴头网格的 GLB 文件,从 RE 五零零零 PBR 资产库分配六种材质,最后在浏览器里用显卡渲染、下载 PNG、保存工程并重新打开。"
},
{
"id": "open-studio",
"title": "载入摄影棚",
"startMs": 39088,
"endMs": 59080,
"narration": "先打开 Web Blender 的 Rendering 工作区。在 PBR 渲染练习面板里点击载入练习摄影棚。这个场景已经准备好相机、三点面光、太阳光、世界环境和地面。等待状态栏显示 Engine SceneIR,并且对象数是六。"
},
{
"id": "import-glb",
"title": "导入猴头 GLB",
"startMs": 74925,
"endMs": 96141,
"narration": "接下来点击导入猴头 GLB,选择课程目录里的 monkey head 点 GLB。导入会追加到当前场景,不会替换摄影棚。看到对象数从六变成七,活动对象是 Suzanne 就对了。此时猴头没有材质,所以是灰色的,这是正常的基线。"
},
{
"id": "asset-library",
"title": "认识 Shading 与 RE 资产库",
"startMs": 111991,
"endMs": 134167,
"narration": "切换到 Shading 工作区。左下角是 PBR 资产库,现在有二十个资产。用资产来源下拉框选择 RE 五零零零 PBR,只留下六张材质卡片。单击卡片只会选中,双击才会把材质分配出去。缩略图加载完成后就可以继续。"
},
{
"id": "assign-materials",
"title": "分配六种材质",
"startMs": 150022,
"endMs": 178630,
"narration": "回到 Rendering 工作区。在 PBR 渲染练习面板下方,新加了 RE 五零零零 PBR 材质选择器,它显示完整材质名和缩略图,不受左侧窄栏影响。依次双击六种材质:防滑颗粒、砖墙、随机瓷砖、光亮瓷砖、规则砖墙和做旧砖墙。每次等待材质槽更新,并确认材质球显示 COMPILED。"
},
{
"id": "render-settings",
"title": "设置渲染",
"startMs": 283150,
"endMs": 301798,
"narration": "现在设置渲染。分辨率选一零二四,相机选场景相机,视图保持最终材质,教学曝光保持零,位移强度保持一倍,视角预设选场景相机对齐。这样得到的就是固定摄影棚下的标准成片。"
},
{
"id": "render-download",
"title": "渲染与下载 PNG",
"startMs": 317656,
"endMs": 335968,
"narration": "点击渲染 PBR。面板会先等待纹理和环境贴图就绪,然后输出一张一零二四乘一零二四的 WebGL2 成片。结果出现后,点击下载 PNG,浏览器会保存 pbr 一零二四点 PNG。"
},
{
"id": "save-reopen",
"title": "保存与重开",
"startMs": 378906,
"endMs": 399474,
"narration": "点击顶部的保存项目,下载点 blend 工程。然后再次导入刚才保存的工程;如果询问是否保存当前修改,选择不保存并继续。重新打开后对象数仍是七,材质槽仍然保留做旧砖墙,再渲染一次确认贴图没有丢失。"
},
{
"id": "diagnostics",
"title": "诊断视图与教学曝光",
"startMs": 416941,
"endMs": 437821,
"narration": "深色材质只看整体亮度容易误判。把视图切到 Base Color,可以看到基础色贴图;切到 Normal,可以看到法线和砖缝;切到 Roughness,可以看到粗糙度。教学曝光可以加一档或两档 EV,只改变渲染曝光,不改变材质本身。"
},
{
"id": "displacement",
"title": "位移与细分",
"startMs": 492012,
"endMs": 513060,
"narration": "Height 贴图现在会真正参与几何位移。位移强度一倍保留源材质零点零零一的 Scale,画面变化很细;切到一百倍只是渲染时的教学放大,不写回材质。静帧会把九百六十八个三角形细分到六万一千九百五十二个,再执行位移。"
},
{
"id": "camera",
"title": "对比视角",
"startMs": 528900,
"endMs": 546420,
"narration": "视角预设默认与场景相机精确对齐,所以场景和视口可能看起来一样。选择对比四十五度后,当前视口会以主体为中心偏转三十度,得到明显不同的构图;主视口导航不受影响。"
},
{
"id": "hq-2048",
"title": "高清 2048 贴图",
"startMs": 562279,
"endMs": 584287,
"narration": "如果需要原始分辨率,点击加载高清贴图二零四八。素材会按需合并,材质名带 HQ 后缀。切换到高清来源,双击分配做旧砖墙 HQ,再渲染一次。五个纹理全部加载,显存预算约六十四点五兆,低于五百一十二兆上限。"
},
{
"id": "summary",
"title": "总结与教案",
"startMs": 625130,
"endMs": 650066,
"narration": "到这里,你已经完成从灰色猴头到六种 PBR 材质的完整流程:打开场景、导入 GLB、分配材质、渲染下载、保存重开,以及诊断、位移、对比相机和高清贴图。更多截图和文字步骤在渲染图文教案里,地址是 tutorial pbr render lesson 点 HTML。"
}
]
}

View File

@@ -10,6 +10,7 @@
* Usage:
* node tools/web/capture-parts-tutorial-screenshots.mjs [--dev] [--port 5176]
*/
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { spawn } from "node:child_process";
@@ -24,9 +25,20 @@ const { chromium } = requireFromWeb("playwright");
const args = process.argv.slice(2);
const useDev = args.includes("--dev");
const landingOnly = args.includes("--landing-only");
const rendersOnly = args.includes("--renders-only");
const portFlag = args.indexOf("--port");
const port = Number.parseInt(portFlag >= 0 ? args[portFlag + 1] : process.env.WEB_TEST_PORT ?? "5176", 10) || 5176;
const baseURL = `http://127.0.0.1:${port}`;
const gpuMode = process.env.GPU_MODE ?? (process.env.DISPLAY ? "hardware" : "swiftshader");
const hardwareArgs = [
"--no-sandbox", "--use-gl=angle", "--use-angle=gl", "--ignore-gpu-blocklist",
"--enable-gpu-rasterization", "--enable-zero-copy", "--enable-unsafe-webgpu",
];
const swiftshaderArgs = [
"--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader",
"--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis",
"--use-webgpu-adapter=swiftshader",
];
const COURSES = {
parts: {
route: "/?tutorial=parts",
@@ -42,6 +54,29 @@ const COURSES = {
frameAll: ["shaft-assemblies", "root-hierarchy", "collections", "materials-assign", "save-export", "summary"],
reopen: "展开二级减速器教程",
},
pbr: {
route: "/?ui=web&tutorial=pbr",
assets: path.join(webDir, "app/public/tutorial/pbr"),
report: path.join(repoRoot, "tests/golden/PBR_TUTORIAL-001"),
frameAll: ["load-studio", "assign-emission", "render-setup", "download-save", "summary"],
reopen: "展开 PBR 渲染教程",
heading: /从零开始 PBR 渲染/,
renderMatrix: {
studio: path.join(webDir, "app/public/tutorial/pbr/studio.blend"),
rendersDir: path.join(webDir, "app/public/tutorial/pbr/renders"),
models: [
{ name: "PBR Suzanne", slug: "suzanne" },
{ name: "PBR Cube", slug: "cube" },
{ name: "PBR Torus", slug: "torus" },
],
materials: [
{ name: "PBR Metal Brushed", slug: "metal-brushed" },
{ name: "PBR Glass", slug: "glass" },
{ name: "PBR Wood", slug: "wood" },
{ name: "PBR Emission Cyan", slug: "emission-cyan" },
],
},
},
};
const courseFlag = args.indexOf("--course");
const courseId = courseFlag >= 0 ? args[courseFlag + 1] : "parts";
@@ -83,16 +118,145 @@ function chromePath() {
return process.env.CHROME_PATH || "/usr/bin/google-chrome";
}
function sha256File(file) {
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
async function openAssetLibrary(page) {
for (let attempt = 0; attempt < 30; attempt += 1) {
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true }).catch(() => undefined);
if (await page.locator(".pbr-asset-library").isVisible().catch(() => false)) return;
await page.waitForTimeout(500);
}
throw new Error("PBR asset library did not become visible");
}
function loadDesktopRenderManifest() {
const manifestPath = path.join(course.assets, "renders", "manifest.json");
if (!fs.existsSync(manifestPath)) return null;
const manifest = JSON.parse(fs.readFileSync(manifestPath, "utf8"));
if ((manifest.renders?.length ?? 0) !== 12) return null;
return { renders: manifest.renders, renderer: manifest.renderer ?? "DESKTOP_BLENDER_EEVEE" };
}
async function captureRenderMatrix(context, report) {
const matrix = course.renderMatrix;
if (!matrix) return;
fs.mkdirSync(matrix.rendersDir, { recursive: true });
const manifest = [];
const page = await context.newPage();
page.on("pageerror", (error) => report.errors.push(`render pageerror: ${error.message}`));
try {
await page.goto(`${baseURL}/?ui=web`);
await page.locator(".blender-app").waitFor({ timeout: 60_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(matrix.studio);
await page.locator(".blender-app[data-project-id]").waitFor({ timeout: 60_000 });
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 60_000 });
await page.waitForFunction(() => /Objects [6-9]/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 60_000 });
await openAssetLibrary(page);
await page.waitForFunction(() => document.querySelectorAll(".pbr-asset-card").length >= 20, undefined, { timeout: 60_000 });
for (const [modelIndex, model] of matrix.models.entries()) {
const before = Number(/Objects (\d+)/.exec(await page.getByTestId("scene-stats").innerText())?.[1] ?? 0);
await page.locator(".pbr-asset-card", { hasText: model.name }).first().dblclick();
await page.waitForFunction((expected) => {
const stats = document.querySelector('[data-testid="scene-stats"]');
const count = Number(/Objects (\d+)/.exec(stats?.textContent ?? "")?.[1] ?? 0);
return count >= expected;
}, before + 1, { timeout: 60_000 });
await page.waitForTimeout(1_000);
for (const material of matrix.materials) {
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
const viewport = page.locator(".viewport-placeholder");
const viewportBox = await viewport.boundingBox().catch(() => null);
if (viewportBox) {
await viewport.click({ position: { x: viewportBox.width / 2, y: viewportBox.height / 2 } });
await page.waitForFunction((name) => document.querySelector('[data-testid="scene-stats"]')?.textContent?.includes(`Active ${name}`), model.name, { timeout: 15_000 }).catch(() => undefined);
}
await page.locator(".pbr-asset-card", { hasText: material.name }).first().dblclick();
await page.waitForFunction((expected) => {
const select = document.querySelector('select[aria-label^="材质槽"]');
return select?.selectedOptions?.[0]?.textContent?.trim().startsWith(expected);
}, material.name, { timeout: 60_000 });
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
await page.locator('[aria-label="PBR 渲染分辨率"]').selectOption("1024");
await page.locator('[aria-label="PBR 渲染相机"]').selectOption("SCENE");
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
await page.waitForFunction(() => {
const image = document.querySelector(".pbr-render-result img");
return image instanceof HTMLImageElement && image.naturalWidth > 0;
}, undefined, { timeout: 120_000 });
const pngPath = path.join(matrix.rendersDir, `${model.slug}-${material.slug}.png`);
const jpgPath = path.join(matrix.rendersDir, `${model.slug}-${material.slug}.jpg`);
const downloadPromise = page.waitForEvent("download", { timeout: 60_000 });
await page.locator(".pbr-render-download").click();
const download = await downloadPromise;
await download.saveAs(pngPath);
const jpegDataUrl = await page.locator(".pbr-render-result img").evaluate(async (image) => {
await image.decode();
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
canvas.getContext("2d").drawImage(image, 0, 0);
return canvas.toDataURL("image/jpeg", 0.9);
});
fs.writeFileSync(jpgPath, Buffer.from(jpegDataUrl.split(",")[1], "base64"));
const entry = {
model: model.name,
modelSlug: model.slug,
material: material.name,
materialSlug: material.slug,
png: path.relative(repoRoot, pngPath).split(path.sep).join("/"),
jpg: path.relative(repoRoot, jpgPath).split(path.sep).join("/"),
width: 1024,
height: 1024,
bytes: fs.statSync(pngPath).size,
sha256: sha256File(pngPath),
};
manifest.push(entry);
report.renders.push(entry);
}
if (modelIndex < matrix.models.length - 1) {
// Reload instead of deleting: library appends can reset Main's active
// object, and a reload releases the native-files Web Lock cleanly.
await page.reload();
await page.locator(".blender-app").waitFor({ timeout: 60_000 });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(matrix.studio);
await page.locator(".blender-app[data-project-id]").waitFor({ timeout: 60_000 });
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 60_000 });
await page.waitForFunction(() => /Objects [6-9]/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 60_000 });
await openAssetLibrary(page);
await page.waitForFunction(() => document.querySelectorAll(".pbr-asset-card").length >= 20, undefined, { timeout: 60_000 });
}
}
}
catch (error) {
report.errors.push(`render matrix: ${error instanceof Error ? error.message : String(error)}`);
await page.screenshot({ path: path.join(reportDir, "failure-render-matrix.png") }).catch(() => undefined);
}
finally {
await page.close().catch(() => undefined);
}
fs.writeFileSync(path.join(matrix.rendersDir, "manifest.json"), `${JSON.stringify({ schemaVersion: 1, renders: manifest }, null, 2)}\n`);
}
async function main() {
const server = launchServer();
let browser;
const report = { schemaVersion: 1, baseURL, startedAt: new Date().toISOString(), steps: [], errors: [] };
const report = { schemaVersion: 2, course: courseId, baseURL, startedAt: new Date().toISOString(), steps: [], renders: [], errors: [] };
try {
await waitForServer();
browser = await chromium.launch({
headless: false,
executablePath: chromePath(),
args: ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader", "--enable-unsafe-webgpu", "--enable-dawn-features=allow_unsafe_apis", "--use-webgpu-adapter=swiftshader"],
args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs,
});
const context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
const page = await context.newPage();
@@ -105,8 +269,20 @@ async function main() {
report.errors.push(`console: ${message.text()}`);
});
if (rendersOnly) {
const desktop = loadDesktopRenderManifest();
if (desktop) {
report.renders = desktop.renders;
report.renderer = desktop.renderer;
}
else await captureRenderMatrix(context, report);
report.completedAt = new Date().toISOString();
report.status = report.errors.length ? "FAILED" : "PASS";
return;
}
await page.goto(`${baseURL}${course.route}`);
await page.getByRole("heading", { name: /从零制作/ }).waitFor({ timeout: 60_000 });
await page.getByRole("heading", { name: course.heading ?? /从零制作/ }).waitFor({ timeout: 60_000 });
await page.evaluate(async () => {
const images = [...document.images];
for (const image of images) image.loading = "eager";
@@ -115,7 +291,7 @@ async function main() {
await page.screenshot({ path: path.join(course.assets, "landing.jpg"), fullPage: true, type: "jpeg", quality: 85 });
if (landingOnly) { report.status = "PASS"; return; }
await page.getByRole("button", { name: /开始学习/ }).click();
await page.getByRole("button", { name: /开始学习|继续学习/ }).click();
await page.locator("[data-tutorial-step]").waitFor({ timeout: 90_000 });
await page.locator('.tutorial-loading[data-tutorial-ready="false"]').waitFor({ state: "detached", timeout: 120_000 });
@@ -183,7 +359,7 @@ async function main() {
const frameAll = course.frameAll.includes(stepId);
await page.getByRole("button", { name: frameAll ? "显示全部" : "框显所选" }).click({ timeout: 5000 }).catch(() => undefined);
await page.waitForTimeout(400);
await page.locator(".viewport-placeholder").screenshot({ path: path.join(outDir, `${stepId}-view.jpg`), type: "jpeg", quality: 85 }).catch(() => undefined);
await page.locator(".viewport-placeholder").screenshot({ path: path.join(outDir, `${stepId}-view.jpg`), type: "jpeg", quality: 85 }).catch(async () => { await page.screenshot({ path: path.join(outDir, `${stepId}-view.jpg`), fullPage: false, type: "jpeg", quality: 85 }).catch(() => undefined); });
await page.getByRole("button", { name: course.reopen }).click();
await page.waitForTimeout(600);
}
@@ -216,6 +392,12 @@ async function main() {
}
await page.screenshot({ path: path.join(outDir, "00-assembly.jpg"), fullPage: false, type: "jpeg", quality: 85 });
const desktopRenders = loadDesktopRenderManifest();
if (desktopRenders) {
report.renders = desktopRenders.renders;
report.renderer = desktopRenders.renderer;
}
else await captureRenderMatrix(context, report);
report.completedAt = new Date().toISOString();
report.status = report.errors.length ? "FAILED" : "PASS";
} catch (error) {

View File

@@ -0,0 +1,356 @@
#!/usr/bin/env node
/**
* Headed Chromium capture for the RE 5000PBR + monkey-head render lesson.
*
* Usage:
* DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \
* node tools/web/capture-re-pbr-lesson.mjs --dev --port 5177 --mode all
*
* Produces:
* tests/golden/RE-PBR-RENDER-001/renders/*.png
* tests/golden/RE-PBR-RENDER-001/report.json
* web/app/public/tutorial/pbr/lesson/renders/*.jpg
* web/app/public/tutorial/pbr/lesson/steps/*.jpg
* web/app/public/tutorial/pbr/lesson/landing.jpg
*/
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const WEB = path.join(REPO, "web");
const requireFromWeb = createRequire(path.join(WEB, "package.json"));
const { chromium } = requireFromWeb("playwright");
const OUT = path.join(REPO, "tests/golden/RE-PBR-RENDER-001");
const LESSON = path.join(REPO, "web/app/public/tutorial/pbr/lesson");
const STUDIO = path.join(REPO, "web/app/public/tutorial/pbr/studio.blend");
const GLB = path.join(REPO, "re/猴头.glb");
const MATERIALS = [
"Black_Anti_Slip_grip",
"Blackish_Brick_Wall_016",
"black_randome_tile",
"black_shinning_tile",
"black_tiles_wall",
"black_worn_wall_brick",
];
const args = process.argv.slice(2);
const flag = (name, fallback) => {
const index = args.indexOf(`--${name}`);
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
};
const useDev = args.includes("--dev");
const port = Number.parseInt(flag("port", process.env.WEB_TEST_PORT ?? "5177"), 10) || 5177;
const baseUrl = `http://127.0.0.1:${port}`;
const mode = flag("mode", "all");
const gpuMode = flag("gpu", process.env.GPU_MODE ?? (process.env.DISPLAY ? "hardware" : "swiftshader"));
const hardwareArgs = [
"--no-sandbox",
"--use-gl=angle",
"--use-angle=gl",
"--ignore-gpu-blocklist",
"--enable-gpu-rasterization",
"--enable-zero-copy",
"--enable-unsafe-webgpu",
];
const swiftshaderArgs = [
"--no-sandbox",
"--use-gl=swiftshader",
"--enable-unsafe-swiftshader",
"--enable-unsafe-webgpu",
"--enable-dawn-features=allow_unsafe_apis",
"--use-webgpu-adapter=swiftshader",
];
fs.mkdirSync(path.join(OUT, "renders"), { recursive: true });
fs.mkdirSync(path.join(LESSON, "renders"), { recursive: true });
fs.mkdirSync(path.join(LESSON, "steps"), { recursive: true });
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
const sha256File = (file) => sha256(fs.readFileSync(file));
async function waitForServer(url, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const response = await fetch(url, { redirect: "manual" });
if (response.status < 500) return;
}
catch { /* retry */ }
await new Promise((resolve) => setTimeout(resolve, 400));
}
throw new Error(`server did not start: ${url}`);
}
function startServer() {
if (useDev) {
const child = spawn(process.execPath, [
path.join(WEB, "node_modules/vite/bin/vite.js"),
"--config", path.join(WEB, "app/vite.config.ts"),
"--host", "127.0.0.1", "--port", String(port), "--strictPort",
], { cwd: WEB, stdio: ["ignore", "pipe", "pipe"] });
child.stdout.on("data", () => undefined);
child.stderr.on("data", () => undefined);
return child;
}
const child = spawn(process.execPath, [path.join(REPO, "tools/web/deployment-http-server.mjs"), "dist"], {
cwd: WEB, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, BLENDER_WEB_HTTP_PORT: String(port) },
});
child.stdout.on("data", () => undefined);
child.stderr.on("data", () => undefined);
return child;
}
const consoleErrors = [];
async function main() {
const server = startServer();
const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
let browser;
try {
await waitForServer(`${baseUrl}/`);
browser = await chromium.launch({
headless: false,
executablePath: chromePath,
args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs,
});
let context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
let page = await context.newPage();
const attachPage = () => {
page.on("pageerror", (error) => consoleErrors.push(error.message));
page.on("console", (message) => {
if (message.type() !== "error") return;
const text = message.text();
if (!/Content Security Policy|style-src/i.test(text)) consoleErrors.push(text);
});
};
attachPage();
const report = {
schemaVersion: 1,
task: "RE-PBR-RENDER-001",
renderer: "WEB_WEBGL2",
headed: true,
gpuMode,
browser: browser.version(),
chromePath,
display: process.env.DISPLAY ?? "",
generatedAt: new Date().toISOString(),
studioSha256: sha256File(STUDIO),
glbSha256: sha256File(GLB),
materials: MATERIALS,
renders: [],
steps: [],
errors: [],
};
const probeGpu = async () => {
const probe = await page.evaluate(async () => {
for (const canvas of document.querySelectorAll("canvas")) {
const gl = canvas.getContext("webgl2");
if (!gl) continue;
const debug = gl.getExtension("WEBGL_debug_renderer_info");
const webgpu = navigator.gpu
? await navigator.gpu.requestAdapter().then((adapter) => adapter
? { isFallbackAdapter: adapter.isFallbackAdapter, info: adapter.info ? { ...adapter.info } : null }
: null).catch(() => null)
: null;
return {
vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : "REDACTED",
renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED",
version: gl.getParameter(gl.VERSION),
webgpu,
};
}
return null;
});
if (!probe) throw new Error("GPU probe: no WebGL2 canvas found");
report.webgl = { vendor: probe.vendor, renderer: probe.renderer, version: probe.version };
report.webgpu = probe.webgpu;
if (gpuMode === "hardware" && /SwiftShader|llvmpipe|software/i.test(String(probe.renderer))) {
throw new Error(`GPU probe failed: hardware mode got ${probe.renderer}`);
}
return probe;
};
const objectCount = async () => {
const text = await page.getByTestId("scene-stats").innerText();
return Number(/Objects (\d+)/.exec(text)?.[1] ?? "0");
};
const openStudio = async () => {
await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" });
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
await page.locator(".blender-app").waitFor({ timeout: 90_000 });
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 90_000 });
await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
const tools = page.locator('[data-testid="native-web-tools-toggle"]');
if (await tools.count()) await tools.click();
};
const importMonkey = async () => {
await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB);
try {
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
}
catch (error) {
await page.screenshot({ path: path.join(OUT, "failure-import.png"), type: "png" }).catch(() => undefined);
const state = await page.evaluate(() => ({
stats: document.querySelector('[data-testid="scene-stats"]')?.textContent,
engine: document.querySelector('[data-testid="engine-status"]')?.textContent,
body: document.body.innerText.slice(0, 600),
})).catch(() => null);
console.error("IMPORT_FAIL", JSON.stringify({ state, consoleErrors: consoleErrors.slice(-8) }));
throw error;
}
};
const openShading = async () => {
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 });
await page.waitForFunction(() => document.querySelectorAll(".pbr-asset-card").length >= 20, undefined, { timeout: 90_000 });
};
const openRendering = async () => {
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
await page.locator(".pbr-render-panel").waitFor({ timeout: 60_000 });
};
const assign = async (name) => {
const card = page.locator(`[data-library="library:re-pbr-v1"]`, { hasText: name }).first();
await card.waitFor({ timeout: 60_000 });
await card.dblclick();
await page.waitForFunction((expected) => {
const select = document.querySelector('select[aria-label^="材质槽"]');
return select instanceof HTMLSelectElement && select.value === `material:${expected}`;
}, name, { timeout: 180_000 });
await page.waitForFunction(() => document.querySelector(".pbr-asset-grid")?.getAttribute("aria-busy") === "false", undefined, { timeout: 180_000 });
};
const render = async (resolution, camera, name, compileStatus = "") => {
await openRendering();
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption(String(resolution));
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption(camera);
await page.locator('select[aria-label="PBR 视角预设"]').selectOption(camera === "VIEWPORT" ? "COMPARISON" : "SCENE_CAMERA");
const previousSrc = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
await page.getByRole("button", { name: "渲染 PBR", exact: true }).click();
await page.waitForFunction(({ expected, previousSrc }) => {
const image = document.querySelector(".pbr-render-result img");
const button = [...document.querySelectorAll(".pbr-render-panel button")]
.find((candidate) => /渲染 PBR|渲染中/.test(candidate.textContent ?? ""));
return image instanceof HTMLImageElement
&& image.naturalWidth === expected
&& image.getAttribute("src") !== previousSrc
&& button?.textContent?.includes("渲染 PBR");
}, { expected: resolution, previousSrc }, { timeout: 180_000 });
const panel = page.locator(".pbr-render-panel");
const textureStatus = await panel.getAttribute("data-texture-status");
const textureAssets = Number(await panel.getAttribute("data-texture-assets") ?? "0");
const stats = await page.evaluate(() => {
const image = document.querySelector(".pbr-render-result img");
if (!(image instanceof HTMLImageElement)) throw new Error("render image missing");
const canvas = document.createElement("canvas");
canvas.width = image.naturalWidth;
canvas.height = image.naturalHeight;
const context = canvas.getContext("2d", { willReadFrequently: true });
if (!context) throw new Error("2d canvas unavailable");
context.drawImage(image, 0, 0);
const data = context.getImageData(0, 0, canvas.width, canvas.height).data;
let sum = 0;
let sumSquared = 0;
let count = 0;
for (let index = 0; index < data.length; index += 4) {
const luminance = (data[index] + data[index + 1] + data[index + 2]) / 3;
sum += luminance;
sumSquared += luminance * luminance;
count += 1;
}
const mean = sum / Math.max(1, count);
return { mean, std: Math.sqrt(Math.max(0, sumSquared / Math.max(1, count) - mean * mean)), jpeg: canvas.toDataURL("image/jpeg", 0.86) };
});
const pngPath = path.join(OUT, "renders", `${name}.png`);
const [download] = await Promise.all([page.waitForEvent("download"), page.locator(".pbr-render-download").click()]);
await download.saveAs(pngPath);
const jpgPath = path.join(LESSON, "renders", `${name}.jpg`);
fs.writeFileSync(jpgPath, Buffer.from(stats.jpeg.split(",")[1], "base64"));
const entry = {
name, material: name.startsWith("material-") ? name.slice("material-".length) : null,
resolution, camera, path: path.relative(REPO, pngPath), jpg: path.relative(REPO, jpgPath),
sha256: sha256File(pngPath), byteLength: fs.statSync(pngPath).size,
mean: Number(stats.mean.toFixed(4)), std: Number(stats.std.toFixed(4)),
textureStatus, textureAssets, compileStatus,
};
report.renders.push(entry);
return entry;
};
const step = async (name) => {
const file = path.join(LESSON, "steps", `${name}.jpg`);
await page.screenshot({ path: file, type: "jpeg", quality: 82 });
report.steps.push({ name, path: path.relative(REPO, file), sha256: sha256File(file) });
};
// Baseline: studio + imported monkey, no library material.
await openStudio();
await probeGpu();
await step("01-open-studio");
await importMonkey();
await step("02-import-glb");
await render(1024, "SCENE", "baseline-unassigned-1024-scene", "BASELINE");
for (const name of MATERIALS) {
await context.close();
context = await browser.newContext({ viewport: { width: 1440, height: 900 }, acceptDownloads: true });
page = await context.newPage();
attachPage();
await openStudio();
await importMonkey();
await openShading();
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1");
await step(`03-asset-library-${name}`);
await assign(name);
await page.waitForFunction(() => /COMPILED|BLOCKED/.test(document.querySelector(".material-preview-status")?.textContent ?? ""), undefined, { timeout: 60_000 });
const compileText = (await page.locator(".material-preview-status").innerText({ timeout: 60_000 })).trim();
const compileStatus = compileText.split("\n").find((line) => /COMPILED|BLOCKED/.test(line))?.trim() ?? compileText;
await step(`04-assign-${name}`);
await render(1024, "SCENE", `material-${name}-1024-scene`, compileStatus);
await render(1024, "VIEWPORT", `material-${name}-1024-viewport`, compileStatus);
await render(512, "SCENE", `material-${name}-512-scene`, compileStatus);
}
// Saved/reopened project re-render (last material persists in the .blend).
const saved = path.join(OUT, "re-pbr-lesson-saved.blend");
await openRendering();
const [blendDownload] = await Promise.all([page.waitForEvent("download"), page.getByRole("button", { name: "保存项目", exact: true }).click()]);
await blendDownload.saveAs(saved);
await page.locator('[data-testid="blend-file-input"]').setInputFiles(saved);
const dialog = page.getByRole("dialog", { name: "保存当前工程的修改?", exact: true });
if (await dialog.count()) {
const discard = dialog.getByRole("button", { name: "不保存,继续", exact: true });
if (await discard.count()) await discard.click();
}
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 90_000 });
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
await step("05-reopen");
await render(1024, "SCENE", `material-${MATERIALS[MATERIALS.length - 1]}-reopen-1024-scene`, "REOPENED");
await page.screenshot({ path: path.join(LESSON, "landing.jpg"), type: "jpeg", quality: 84 });
report.status = report.renders.length === 20 && report.errors.length === 0 ? "PASS" : "FAIL";
report.consoleErrors = consoleErrors;
fs.writeFileSync(path.join(OUT, "report.json"), `${JSON.stringify(report, null, 2)}\n`);
console.log(JSON.stringify({
status: report.status,
renders: report.renders.length,
steps: report.steps.length,
errors: report.errors.length + consoleErrors.length,
report: path.relative(REPO, path.join(OUT, "report.json")),
}, null, 2));
if (report.status !== "PASS") process.exitCode = 1;
}
finally {
if (browser) await browser.close().catch(() => undefined);
server.kill("SIGTERM");
}
}
main().catch((error) => {
console.error(error);
process.exitCode = 1;
});

View File

@@ -23,7 +23,7 @@ assert.deepEqual(contract.responseHeaders.allResponses, {
"Cross-Origin-Opener-Policy": "same-origin",
"Cross-Origin-Embedder-Policy": "require-corp",
"Cross-Origin-Resource-Policy": "same-origin",
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self' blob:; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
"Content-Security-Policy": "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self'; style-src-attr 'unsafe-inline'; worker-src 'self'; connect-src 'self'; font-src 'self'; img-src 'self' blob:; media-src 'self' blob:; object-src 'none'; base-uri 'none'; frame-ancestors 'none'",
});
const routes = Object.fromEntries(contract.responseHeaders.routes.map((route) => [route.id, route]));
assert.deepEqual(routes["entry-document"], { id: "entry-document", patterns: ["/", "/index.html"], cacheControl: "no-cache" });
@@ -52,11 +52,12 @@ for (const [extension, mime] of Object.entries({
".ogg": "audio/ogg",
".mp4": "video/mp4",
".webm": "video/webm",
".vtt": "text/vtt; charset=utf-8",
".blend": "application/octet-stream",
".nvdb": "application/x-nanovdb",
})) assert.equal(contract.mimeTypes[extension], mime, `${extension} MIME drifted`);
assert.deepEqual(contract.rangeRequests.extensions, [".wasm", ".blend", ".nvdb"]);
assert.deepEqual(contract.rangeRequests.extensions, [".wasm", ".blend", ".nvdb", ".mp4", ".webm"]);
assert.equal(contract.rangeRequests.unit, "bytes");
assert.equal(contract.rangeRequests.singleRangeOnly, true);
assert.equal(contract.rangeRequests.etag, "strong-sha256");

View File

@@ -41,6 +41,8 @@ const fixtures = new Map([
["/engine-manifest.json", write("engine-manifest.json", "{\"schemaVersion\":2}\n")],
["/media/frame.png", write("media/frame.png", Buffer.from([0x89, 0x50, 0x4e, 0x47]))],
["/media/silence.wav", write("media/silence.wav", Buffer.from("RIFF0000WAVE"))],
["/media/lesson.mp4", write("media/lesson.mp4", deterministicBytes(4096, 17))],
["/media/lesson.vtt", write("media/lesson.vtt", "WEBVTT\n\n00:00.000 --> 00:02.000\nHello\n")],
["/fixtures/basic.blend", write("fixtures/basic.blend", deterministicBytes(4096, 11))],
["/volumes/smoke.nvdb", write("volumes/smoke.nvdb", deterministicBytes(1024 * 1024, 19))],
]);
@@ -111,6 +113,8 @@ try {
["/engine-manifest.json", ".json"],
["/media/frame.png", ".png"],
["/media/silence.wav", ".wav"],
["/media/lesson.mp4", ".mp4"],
["/media/lesson.vtt", ".vtt"],
["/fixtures/basic.blend", ".blend"],
["/volumes/smoke.nvdb", ".nvdb"],
];
@@ -134,6 +138,7 @@ try {
await expectRange("/vendor/blender/single/web_engine.wasm", "bytes=100-199", 100, 199);
await expectRange("/fixtures/basic.blend", "bytes=200-399", 200, 399);
await expectRange("/volumes/smoke.nvdb", "bytes=300-599", 300, 599);
await expectRange("/media/lesson.mp4", "bytes=100-199", 100, 199);
await expectRange("/fixtures/basic.blend", "bytes=400-", 400, fixtures.get("/fixtures/basic.blend").length - 1);
await expectRange("/fixtures/basic.blend", "bytes=-128", fixtures.get("/fixtures/basic.blend").length - 128, fixtures.get("/fixtures/basic.blend").length - 1);
@@ -189,7 +194,7 @@ try {
assert.equal((await fetch(`${privateOrigin}/api/server-operator-job`, { method: "POST", headers: { ...privateHeaders, Origin: "https://untrusted.invalid" } })).status, 403);
await assert.rejects(listenDeploymentHttpServer(createDeploymentHttpServer({ root, contract, accessPassword: "" }), { host: "0.0.0.0" }), /WEB_BLENDER_PASSWORD/);
} finally { await new Promise((resolve) => privateServer.close(resolve)); }
process.stdout.write("deployment-http-ok mime=10 head=4 ranges=5 invalidRanges=6 resumeSha256=verified\n");
process.stdout.write("deployment-http-ok mime=12 head=4 ranges=6 invalidRanges=6 resumeSha256=verified\n");
}
finally {
if (server.listening) await new Promise((resolve) => server.close(resolve));

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const assets = path.join(repoRoot, "web/app/public/tutorial/pbr");
const stepsDir = path.join(assets, "steps");
const rendersDir = path.join(assets, "renders");
const reportDir = path.join(repoRoot, "tests/golden/PBR_TUTORIAL-001");
const stepIds = [
"intro", "workspace-shading", "load-studio", "add-suzanne", "add-cube", "add-torus",
"assign-metal", "assign-glass", "assign-wood", "assign-emission",
"render-setup", "download-save", "summary",
];
const models = ["suzanne", "cube", "torus"];
const materials = ["metal-brushed", "glass", "wood", "emission-cyan"];
const errors = [];
function requireFile(file, minimum = 1) {
if (!fs.existsSync(file)) {
errors.push(`missing: ${path.relative(repoRoot, file)}`);
return;
}
if (fs.statSync(file).size < minimum) errors.push(`empty: ${path.relative(repoRoot, file)}`);
}
function pngSize(file) {
const bytes = fs.readFileSync(file);
if (bytes.length < 24 || bytes.toString("ascii", 1, 4) !== "PNG") throw new Error(`not a PNG: ${file}`);
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
}
function sha256(file) {
return crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
}
for (const stepId of stepIds) {
requireFile(path.join(stepsDir, `${stepId}.jpg`), 1024);
requireFile(path.join(stepsDir, `${stepId}-view.jpg`), 1024);
}
requireFile(path.join(assets, "landing.jpg"), 1024);
requireFile(path.join(stepsDir, "00-assembly.jpg"), 1024);
for (const model of models) {
for (const material of materials) {
const png = path.join(rendersDir, `${model}-${material}.png`);
const jpg = path.join(rendersDir, `${model}-${material}.jpg`);
requireFile(png, 1024);
requireFile(jpg, 1024);
if (fs.existsSync(png)) {
try {
const { width, height } = pngSize(png);
if (width !== 1024 || height !== 1024) errors.push(`${path.basename(png)} is ${width}x${height}, expected 1024x1024`);
}
catch (error) {
errors.push(error instanceof Error ? error.message : String(error));
}
}
}
}
const studioBlend = path.join(assets, "studio.blend");
const studioReport = path.join(reportDir, "studio-build.json");
requireFile(studioBlend, 1024);
requireFile(studioReport);
if (fs.existsSync(studioBlend) && fs.existsSync(studioReport)) {
const report = JSON.parse(fs.readFileSync(studioReport, "utf8"));
if (report.blend?.sha256 !== sha256(studioBlend)) errors.push("studio-build.json SHA-256 does not match studio.blend");
}
const latestReport = path.join(reportDir, "headed-screenshots-latest.json");
requireFile(latestReport);
if (fs.existsSync(latestReport)) {
const report = JSON.parse(fs.readFileSync(latestReport, "utf8"));
if (report.status !== "PASS") errors.push(`headed report status is ${report.status}`);
if (report.course !== "pbr") errors.push("headed report course is not pbr");
if ((report.steps?.length ?? 0) !== stepIds.length) errors.push(`headed report has ${report.steps?.length ?? 0} steps`);
if ((report.renders?.length ?? 0) !== 12) errors.push(`headed report has ${report.renders?.length ?? 0} renders`);
if ((report.errors?.length ?? 0) !== 0) errors.push(`headed report has ${report.errors.length} errors`);
}
const renderManifest = path.join(rendersDir, "manifest.json");
requireFile(renderManifest);
if (fs.existsSync(renderManifest)) {
const manifest = JSON.parse(fs.readFileSync(renderManifest, "utf8"));
if ((manifest.renders?.length ?? 0) !== 12) errors.push(`render manifest has ${manifest.renders?.length ?? 0} entries`);
for (const entry of manifest.renders ?? []) {
if (!fs.existsSync(path.join(repoRoot, entry.png))) errors.push(`render manifest missing ${entry.png}`);
if (!fs.existsSync(path.join(repoRoot, entry.jpg))) errors.push(`render manifest missing ${entry.jpg}`);
}
}
if (errors.length) {
console.error(JSON.stringify({ status: "FAIL", errors }, null, 2));
process.exit(1);
}
console.log(JSON.stringify({ status: "PASS", steps: stepIds.length, renders: 12 }, null, 2));

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env node
/** Validate the RE 5000PBR render matrix, lesson assets and static HTML. */
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const REPO = path.resolve(import.meta.dirname, "../..");
const OUT = path.join(REPO, "tests/golden/RE-PBR-RENDER-001");
const LESSON = path.join(REPO, "web/app/public/tutorial/pbr");
const GLB_SOURCE = path.join(REPO, "re/猴头.glb");
const GLB_FIXTURE = path.join(REPO, "tests/files/web/pbr-render-lesson/monkey-head.glb");
const MATERIALS = [
"Black_Anti_Slip_grip",
"Blackish_Brick_Wall_016",
"black_randome_tile",
"black_shinning_tile",
"black_tiles_wall",
"black_worn_wall_brick",
];
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const pngSize = (file) => {
const bytes = fs.readFileSync(file);
assert.equal(bytes.subarray(0, 8).toString("hex"), "89504e470d0a1a0a", `not a PNG: ${file}`);
return { width: bytes.readUInt32BE(16), height: bytes.readUInt32BE(20) };
};
const report = readJson(path.join(OUT, "report.json"));
assert.equal(report.status, "PASS");
assert.equal(report.renderer, "WEB_WEBGL2");
assert.equal(report.headed, true);
assert.equal(report.renders.length, 20, "expected 20 renders");
assert.equal(report.steps.length, 15, "expected 15 lesson steps");
assert.equal(report.errors.length, 0);
assert.equal(sha256(fs.readFileSync(GLB_SOURCE)), sha256(fs.readFileSync(GLB_FIXTURE)), "GLB fixture SHA mismatch");
assert.equal(report.glbSha256, sha256(fs.readFileSync(GLB_FIXTURE)));
const materialScene = new Map();
for (const render of report.renders) {
const file = path.join(REPO, render.path);
assert.ok(fs.existsSync(file), `missing render: ${render.path}`);
assert.equal(sha256(fs.readFileSync(file)), render.sha256, `render SHA mismatch: ${render.path}`);
assert.equal(fs.statSync(file).size, render.byteLength, `render byte length mismatch: ${render.path}`);
const size = pngSize(file);
assert.equal(size.width, render.resolution, `render width mismatch: ${render.path}`);
assert.equal(size.height, render.resolution, `render height mismatch: ${render.path}`);
assert.ok(render.mean > 1 && render.mean < 254, `render appears blank: ${render.path} mean=${render.mean}`);
assert.ok(render.textureStatus?.startsWith("ready:"), `texture status not ready: ${render.path} ${render.textureStatus}`);
assert.ok(render.textureAssets > 0, `no texture assets: ${render.path}`);
if (render.camera === "SCENE" && render.resolution === 1024 && render.material && !render.name.includes("reopen")) {
materialScene.set(render.material, render.sha256);
}
assert.ok(fs.existsSync(path.join(REPO, render.jpg)), `missing lesson JPG: ${render.jpg}`);
}
assert.equal(materialScene.size, 6, "missing 1024 scene renders for six materials");
assert.ok(report.renders.every((render) => !String(render.compileStatus ?? "").includes("BLOCKED")), "a material compile was BLOCKED");
assert.ok(report.renders.filter((render) => String(render.compileStatus ?? "").includes("COMPILED")).length >= 6, "six RE materials must compile");
assert.equal(new Set(materialScene.values()).size, 6, "six material renders are not distinct");
const baseline = report.renders.find((render) => render.name === "baseline-unassigned-1024-scene");
assert.ok(baseline, "baseline render missing");
assert.ok(![...materialScene.values()].includes(baseline.sha256), "baseline identical to a material render");
assert.ok(report.renders.some((render) => render.name.endsWith("-reopen-1024-scene")), "reopen render missing");
const htmlPath = path.join(LESSON, "render-lesson.html");
const cssPath = path.join(LESSON, "render-lesson.css");
assert.ok(fs.existsSync(htmlPath), "render-lesson.html missing");
assert.ok(fs.existsSync(cssPath), "render-lesson.css missing");
const html = fs.readFileSync(htmlPath, "utf8");
assert.ok(!/<script\b/i.test(html), "lesson must not contain inline scripts");
assert.ok(!/<style\b/i.test(html), "lesson must not contain inline style blocks");
assert.ok(!/\son[a-z]+\s*=/i.test(html), "lesson must not contain inline on* handlers");
assert.ok(!/https?:\/\//i.test(html), "lesson must not reference remote URLs");
assert.ok(html.includes("20 张 Web/WebGL2 成片"), "lesson must state the 20-render result");
const sources = [...html.matchAll(/(?:src|href)="([^"]+)"/g)].map((match) => match[1]);
for (const source of sources) {
if (source.startsWith("#") || source.startsWith("/?") || source.startsWith("mailto:")) continue;
assert.ok(source.startsWith("/"), `relative lesson URL is not supported: ${source}`);
const file = path.join(REPO, "web/app/public", source.slice(1));
assert.ok(fs.existsSync(file), `lesson asset missing: ${source}`);
}
for (const match of html.matchAll(/poster="([^"]+)"/g)) {
const source = match[1];
assert.ok(source.startsWith("/"), `relative poster is not supported: ${source}`);
assert.ok(fs.existsSync(path.join(REPO, "web/app/public", source.slice(1))), `lesson poster missing: ${source}`);
}
for (const material of MATERIALS) {
assert.ok(html.includes(`material-${material}-1024-scene.jpg`), `lesson misses ${material} render`);
assert.ok(html.includes(material), `lesson misses ${material} name`);
}
for (const step of report.steps) {
assert.ok(fs.existsSync(path.join(REPO, step.path)), `missing step image: ${step.path}`);
}
assert.ok(fs.existsSync(path.join(LESSON, "lesson/landing.jpg")), "landing image missing");
console.log(JSON.stringify({
status: "PASS",
renders: report.renders.length,
steps: report.steps.length,
materials: materialScene.size,
baselineMean: baseline.mean,
lesson: path.relative(REPO, htmlPath),
}, null, 2));

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env node
/** Validate the derived RE 5000PBR asset library and its preview/manifest identity. */
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const REPO = path.resolve(import.meta.dirname, "../..");
const args = process.argv.slice(2);
const flag = (name, fallback) => {
const index = args.indexOf(`--${name}`);
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
};
const HQ = args.includes("--hq");
const DIR = path.resolve(args.find((value) => !value.startsWith("--") && value !== "hq") ?? flag("dir", path.join(REPO, HQ ? "web/app/public/assets/re-pbr-hq" : "web/app/public/assets/re-pbr")));
const BLENDER = path.join(REPO, "build_blender_native/bin/blender");
const LIBRARY_ID = flag("library-id", HQ ? "library:re-pbr-hq-v1" : "library:re-pbr-v1");
const LIBRARY_NAME = flag("library-name", HQ ? "RE 5000PBR HQ" : "RE 5000PBR");
const LIBRARY_SOURCE = flag("source", HQ ? "libraries/re-pbr-hq-library.blend" : "libraries/re-pbr-library.blend");
const EXPECTED_MATERIALS = 6;
const MAX_LIBRARY_BYTES = Number(flag("max-library-bytes", HQ ? 16 * 1024 * 1024 : 5 * 1024 * 1024));
const MAX_PREVIEW_BYTES = 128 * 1024;
const MAX_TEXTURE_SIZE = Number(flag("max-texture-size", HQ ? 2048 : 1024));
const SUFFIX = flag("suffix", HQ ? "_HQ" : "");
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
const readJson = (name) => JSON.parse(fs.readFileSync(path.join(DIR, name), "utf8"));
const assertAscii = (name, value) => assert.match(value, /^[\x20-\x7e]+$/, `${name} must be ASCII: ${value}`);
const manifest = readJson("manifest.json");
const report = readJson("build-report.json");
const previewManifest = readJson("preview-manifest.json");
assert.equal(manifest.schemaVersion, 2);
assert.equal(manifest.libraries.length, 1);
assert.equal(manifest.libraries[0].libraryId, LIBRARY_ID);
assert.equal(manifest.libraries[0].name, LIBRARY_NAME);
assert.equal(manifest.libraries[0].sourcePath, LIBRARY_SOURCE);
assert.equal(manifest.libraries[0].readOnly, true);
assert.equal(manifest.assets.length, EXPECTED_MATERIALS);
assert.ok(manifest.assets.every((asset) => asset.idType === "MATERIAL"), "all assets must be MATERIAL");
assert.ok(manifest.assets.every((asset) => asset.assetLibraryIdentifier === LIBRARY_ID));
assert.ok(manifest.assets.every((asset) => asset.preview), "every asset needs a preview");
const libraryPath = path.join(DIR, "re-pbr-library.blend");
const libraryBytes = fs.readFileSync(libraryPath);
const librarySha = sha256(libraryBytes);
assert.equal(librarySha, manifest.libraries[0].sourceSha256, "manifest library SHA mismatch");
assert.equal(librarySha, report.library.sha256, "build report library SHA mismatch");
assert.ok(libraryBytes.byteLength <= MAX_LIBRARY_BYTES, `library exceeds ${MAX_LIBRARY_BYTES} bytes`);
assert.equal(report.materials.length, EXPECTED_MATERIALS);
assert.equal(previewManifest.previews.length, EXPECTED_MATERIALS);
for (const name of ["manifest.json", "build-report.json", "preview-manifest.json", "blender_assets.cats.txt", "re-pbr-library.blend"]) {
assertAscii("file", name);
}
for (const asset of manifest.assets) {
assertAscii("asset name", asset.name);
assert.ok(asset.name !== "Dots Stroke", "Dots Stroke must not be exported");
if (SUFFIX) assert.ok(asset.name.endsWith(SUFFIX), `asset name misses suffix ${SUFFIX}: ${asset.name}`);
assert.ok(asset.preview.byteLength <= MAX_PREVIEW_BYTES, `preview too large: ${asset.name}`);
const previewPath = path.join(DIR, "previews", `${asset.preview.sha256}.png`);
const previewBytes = fs.readFileSync(previewPath);
assert.equal(sha256(previewBytes), asset.preview.sha256, `preview SHA mismatch: ${asset.name}`);
assert.equal(previewBytes.byteLength, asset.preview.byteLength, `preview size mismatch: ${asset.name}`);
assert.equal(asset.preview.width, 256);
assert.equal(asset.preview.height, 256);
}
const blenderExpr = `
import bpy, json
result = {"materials": {}, "errors": []}
expected = {"FRAME", "REROUTE", "DISPLACEMENT", "MAPPING", "VALUE", "BSDF_PRINCIPLED", "NORMAL_MAP"}
for material in bpy.data.materials:
if material.name == "Dots Stroke" or not material.use_nodes or material.node_tree is None:
continue
types = {node.type for node in material.node_tree.nodes}
images = []
for node in material.node_tree.nodes:
if node.type == "TEX_IMAGE" and node.image is not None:
image = node.image
images.append({
"name": image.name,
"size": list(image.size[:]),
"colorspace": image.colorspace_settings.name,
"packed": bool(image.packed_file),
"packedBytes": image.packed_file.size if image.packed_file else 0,
"fileFormat": image.file_format,
})
result["materials"][material.name] = {"types": sorted(types), "images": images}
print("CHECK_JSON:" + json.dumps(result))
`;
const output = execFileSync(BLENDER, ["-b", libraryPath, "--python-expr", blenderExpr], {
encoding: "utf8",
timeout: 120_000,
maxBuffer: 8 * 1024 * 1024,
});
const line = output.split(/\r?\n/).find((value) => value.startsWith("CHECK_JSON:"));
assert.ok(line, "Blender inspection produced no CHECK_JSON line");
const inspection = JSON.parse(line.slice("CHECK_JSON:".length));
assert.equal(Object.keys(inspection.materials).length, EXPECTED_MATERIALS);
for (const [name, info] of Object.entries(inspection.materials)) {
const types = new Set(info.types);
for (const expectedType of ["FRAME", "REROUTE", "DISPLACEMENT", "MAPPING", "VALUE", "BSDF_PRINCIPLED", "NORMAL_MAP"]) {
assert.ok(types.has(expectedType), `${name} lost node ${expectedType}`);
}
assert.equal(info.images.length, 4, `${name} must keep four image textures`);
for (const image of info.images) {
assert.ok(image.packed, `${image.name} is not packed`);
assert.ok(image.size[0] > 0 && image.size[1] > 0, `${image.name} has no pixels`);
assert.ok(image.size[0] <= MAX_TEXTURE_SIZE && image.size[1] <= MAX_TEXTURE_SIZE, `${image.name} exceeds ${MAX_TEXTURE_SIZE}px`);
assert.equal(image.fileFormat, "JPEG", `${image.name} is not JPEG`);
assert.ok(image.colorspace === "sRGB" || image.colorspace === "Non-Color", `${image.name} colorspace ${image.colorspace}`);
if (image.name.toLowerCase().includes("base_color")) assert.equal(image.colorspace, "sRGB");
else assert.equal(image.colorspace, "Non-Color");
}
}
const summary = {
status: "PASS",
libraryId: LIBRARY_ID,
librarySha256: librarySha,
libraryBytes: libraryBytes.byteLength,
materials: manifest.assets.length,
previews: manifest.assets.length,
previewBytes: manifest.assets.reduce((sum, asset) => sum + asset.preview.byteLength, 0),
textureBytes: report.textures.reduce((sum, item) => sum + item.packedBytes, 0),
};
console.log(JSON.stringify(summary, null, 2));

View File

@@ -0,0 +1,99 @@
#!/usr/bin/env node
/** Validate the RE 5000PBR limitation fixes: displacement, diagnostics, HQ, GPU and camera. */
import assert from "node:assert/strict";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const REPO = path.resolve(import.meta.dirname, "../..");
const LIMITS = path.join(REPO, "tests/golden/RE-PBR-LIMITS-001");
const RENDER = path.join(REPO, "tests/golden/RE-PBR-RENDER-001");
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const exists = (file, label) => assert.ok(fs.existsSync(file), `missing ${label}: ${path.relative(REPO, file)}`);
const displacement = readJson(path.join(LIMITS, "displacement/report.json"));
assert.equal(displacement.status, "PASS");
assert.equal(displacement.nativeBinding.usage, "HEIGHT");
assert.ok(Math.abs(displacement.nativeBinding.scale - 0.001) < 1e-6);
assert.ok(Math.abs(displacement.nativeBinding.midlevel - 0.5) < 1e-6);
assert.equal(displacement.subdivision.sourceTriangles, 968);
assert.equal(displacement.subdivision.renderTriangles, 61952);
for (const key of ["1x", "100x"]) {
const render = displacement.renders[key];
const file = path.join(REPO, render.path);
exists(file, `displacement ${key}`);
assert.equal(sha256(file), render.sha256, `displacement ${key} SHA`);
assert.ok(render.mean > 1 && render.mean < 254);
}
assert.notEqual(displacement.renders["1x"].sha256, displacement.renders["100x"].sha256);
assert.ok(displacement.meanDelta > 0.01, "displacement 1x/100x mean delta too small");
const diagnostics = readJson(path.join(LIMITS, "diagnostics/report.json"));
assert.equal(diagnostics.status, "PASS");
assert.equal(diagnostics.views.length, 3);
for (const view of diagnostics.views) {
const file = path.join(REPO, view.path);
exists(file, `diagnostic ${view.mode}`);
assert.equal(sha256(file), view.sha256, `diagnostic ${view.mode} SHA`);
assert.ok(view.mean > 1 && view.mean < 254);
}
assert.equal(diagnostics.exposure.result, "PASS");
assert.equal(new Set(diagnostics.views.map((view) => view.sha256)).size, 3, "diagnostic views must differ");
const hq = readJson(path.join(LIMITS, "hq/report.json"));
assert.equal(hq.status, "PASS");
exists(path.join(REPO, hq.library.path), "HQ library");
assert.equal(sha256(path.join(REPO, hq.library.path)), hq.library.sha256);
assert.equal(hq.budget.loaded, 5);
assert.equal(hq.budget.rejected, 0);
assert.ok(hq.budget.gpuDecodedBytes < 512 * 1024 * 1024, "HQ exceeds WebGL2 decoded budget");
exists(path.join(REPO, hq.render.path), "HQ render");
assert.equal(sha256(path.join(REPO, hq.render.path)), hq.render.sha256);
const gpu = readJson(path.join(LIMITS, "gpu/batch0-gpu-enable.json"));
assert.equal(gpu.status, "PASS");
assert.match(gpu.webgl.renderer, /NVIDIA|AMD|Intel|ANGLE/);
assert.doesNotMatch(gpu.webgl.renderer, /SwiftShader|llvmpipe|software/i);
const hardwareRender = gpu.render.hardware;
const hardwareFile = path.join(REPO, hardwareRender.path);
exists(hardwareFile, "GPU hardware render");
assert.equal(sha256(hardwareFile), hardwareRender.sha256);
assert.match(gpu.render.swiftshader.sha256, /^[a-f0-9]{64}$/);
assert.ok(gpu.render.meanStd.swiftshader.mean > 1 && gpu.render.meanStd.swiftshader.mean < 254);
const renderReport = readJson(path.join(RENDER, "report.json"));
assert.equal(renderReport.status, "PASS");
assert.equal(renderReport.renders.length, 20);
assert.equal(new Set(renderReport.renders.map((render) => render.sha256)).size, 20, "20 renders must be unique");
assert.match(renderReport.webgl.renderer, /NVIDIA|AMD|Intel|ANGLE/);
const last = "material-black_worn_wall_brick";
const scene = renderReport.renders.find((render) => render.name === `${last}-1024-scene`);
const viewport = renderReport.renders.find((render) => render.name === `${last}-1024-viewport`);
assert.ok(scene && viewport);
assert.notEqual(scene.sha256, viewport.sha256, "SCENE and COMPARISON renders must differ");
const reopen = renderReport.renders.find((render) => render.name.endsWith("-reopen-1024-scene"));
assert.ok(reopen);
assert.match(reopen.textureStatus, /^ready:([1-9]\d*):0:/);
const lessonHtml = path.join(REPO, "web/app/public/tutorial/pbr/render-lesson.html");
exists(lessonHtml, "lesson HTML");
const html = fs.readFileSync(lessonHtml, "utf8");
for (const required of ["7. 进阶", "位移与细分", "诊断视图", "高清 2048", "RTX 4060"]) {
assert.ok(html.includes(required), `lesson misses ${required}`);
}
for (const source of [...html.matchAll(/src="([^"]+)"/g)].map((match) => match[1])) {
if (!source.startsWith("/")) continue;
exists(path.join(REPO, "web/app/public", source.slice(1)), `lesson asset ${source}`);
}
console.log(JSON.stringify({
status: "PASS",
displacement: { scale: displacement.nativeBinding.scale, triangles: displacement.subdivision.renderTriangles, meanDelta: displacement.meanDelta },
diagnostics: diagnostics.views.length,
hq: { bytes: hq.library.bytes, gpuDecodedBytes: hq.budget.gpuDecodedBytes },
gpu: gpu.webgl.renderer,
renders: renderReport.renders.length,
uniqueRenders: 20,
reopen: reopen.textureStatus,
}, null, 2));

View File

@@ -0,0 +1,95 @@
#!/usr/bin/env node
/** Validate the RE 5000PBR lesson MP4, narration, VTT, poster and chapter screenshots. */
import assert from "node:assert/strict";
import { execFileSync } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
const REPO = path.resolve(import.meta.dirname, "../..");
const OUT = path.join(REPO, "tests/golden/RE-PBR-VIDEO-001");
const LESSON = path.join(REPO, "web/app/public/tutorial/pbr/lesson");
const VIDEO_DIR = path.join(LESSON, "video");
const VIDEO = path.join(VIDEO_DIR, "re-pbr-lesson-720p.mp4");
const VTT = path.join(VIDEO_DIR, "re-pbr-lesson.zh-Hans.vtt");
const POSTER = path.join(VIDEO_DIR, "poster.jpg");
const HTML = path.join(REPO, "web/app/public/tutorial/pbr/render-lesson.html");
const FFPROBE = "/usr/bin/ffprobe";
const sha256 = (file) => crypto.createHash("sha256").update(fs.readFileSync(file)).digest("hex");
const exists = (file, label) => assert.ok(fs.existsSync(file), `missing ${label}: ${path.relative(REPO, file)}`);
const readJson = (file) => JSON.parse(fs.readFileSync(file, "utf8"));
const probe = (file) => JSON.parse(execFileSync(FFPROBE, ["-v", "error", "-show_entries",
"format=duration,size,format_name:stream=codec_type,codec_name,width,height", "-of", "json", file], { encoding: "utf8" }));
const report = readJson(path.join(OUT, "video-report.json"));
const timeline = readJson(path.join(OUT, "timeline.json"));
const narration = readJson(path.join(OUT, "narration.json"));
assert.equal(report.status, "PASS");
assert.equal(timeline.status, "PASS");
assert.equal(report.chapters.length, 13);
assert.equal(timeline.chapters.length, 13);
assert.equal(narration.chapters.length, 13);
for (const file of [VIDEO, VTT, POSTER]) exists(file, path.basename(file));
assert.equal(sha256(VIDEO), report.video.sha256, "video SHA mismatch");
assert.equal(sha256(POSTER), report.poster.sha256, "poster SHA mismatch");
assert.equal(sha256(VTT), report.vtt.sha256, "VTT SHA mismatch");
const media = probe(VIDEO);
const duration = Number(media.format?.duration ?? 0);
assert.ok(duration >= 420 && duration <= 720, `video duration ${duration}s outside 7-12 minutes`);
assert.match(String(media.format?.format_name ?? ""), /mp4/);
const videoStream = media.streams.find((stream) => stream.codec_type === "video");
const audioStream = media.streams.find((stream) => stream.codec_type === "audio");
assert.ok(videoStream && audioStream, "video/audio streams missing");
assert.equal(videoStream.codec_name, "h264");
assert.equal(videoStream.width, 1280);
assert.equal(videoStream.height, 720);
assert.equal(audioStream.codec_name, "aac");
const head = fs.readFileSync(VIDEO).subarray(0, 2 * 1024 * 1024);
const moov = head.indexOf(Buffer.from("moov"));
const mdat = head.indexOf(Buffer.from("mdat"));
assert.ok(moov >= 0 && mdat >= 0 && moov < mdat, "MP4 is not faststart (moov must precede mdat)");
const vttText = fs.readFileSync(VTT, "utf8");
assert.ok(vttText.startsWith("WEBVTT"));
const cues = [...vttText.matchAll(/^(\d{2}:\d{2}:\d{2}\.\d{3}) --> (\d{2}:\d{2}:\d{2}\.\d{3})$/gm)];
assert.equal(cues.length, 13, `expected 13 VTT cues, got ${cues.length}`);
let previous = -1;
for (const cue of cues) {
const start = cue[1];
const parts = start.split(":").map(Number);
const seconds = parts[0] * 3600 + parts[1] * 60 + parts[2];
assert.ok(seconds >= previous, "VTT cue times must be monotonic");
previous = seconds;
}
const posterMedia = probe(POSTER);
const posterStream = posterMedia.streams.find((stream) => stream.codec_type === "video");
assert.equal(posterStream.width, 1280);
assert.equal(posterStream.height, 720);
for (const chapter of timeline.chapters) {
const file = path.join(REPO, chapter.screenshot);
exists(file, `chapter screenshot ${chapter.id}`);
const mediaInfo = probe(file);
const stream = mediaInfo.streams.find((item) => item.codec_type === "video");
assert.equal(stream.width, 1600, `chapter ${chapter.id} width`);
assert.equal(stream.height, 900, `chapter ${chapter.id} height`);
}
assert.equal(new Set(timeline.chapters.map((chapter) => chapter.id)).size, 13);
const html = fs.readFileSync(HTML, "utf8");
assert.ok(html.includes('class="lesson-video"'));
assert.ok(html.includes('type="video/mp4"'));
assert.ok(html.includes('re-pbr-lesson.zh-Hans.vtt'));
assert.ok(html.includes('poster="/tutorial/pbr/lesson/video/poster.jpg"'));
assert.ok(!/https?:\/\//i.test(html), "lesson must not reference remote URLs");
console.log(JSON.stringify({
status: "PASS",
video: { path: path.relative(REPO, VIDEO), bytes: fs.statSync(VIDEO).size, durationSeconds: Number(duration.toFixed(2)), codecs: { video: videoStream.codec_name, audio: audioStream.codec_name }, resolution: `${videoStream.width}x${videoStream.height}`, faststart: true },
narration: { voice: narration.voice, chapters: narration.chapters.length },
vttCues: cues.length,
chapters: timeline.chapters.length,
poster: "1280x720",
}, null, 2));

View File

@@ -42,9 +42,15 @@ function resolvedFile(root, pathname) {
return filePath;
}
function strongEtag(filePath) {
const etagCache = new Map();
function strongEtag(filePath, stat) {
const key = path.resolve(filePath);
const cached = etagCache.get(key);
if (cached && cached.size === stat.size && cached.mtimeMs === stat.mtimeMs) return cached.etag;
const hash = crypto.createHash("sha256").update(fs.readFileSync(filePath)).digest("hex");
return `"sha256-${hash}"`;
const etag = `"sha256-${hash}"`;
etagCache.set(key, { size: stat.size, mtimeMs: stat.mtimeMs, etag });
return etag;
}
function matchesIfNoneMatch(value, etag) {
@@ -121,7 +127,7 @@ export function createDeploymentHttpServer({ root, contract = loadDeploymentCont
const extension = path.extname(filePath).toLowerCase();
if (expectedAuthorization) { headers["Cache-Control"] = "private, no-store"; headers.Vary = "Authorization"; }
const rangeEnabled = contract.rangeRequests.extensions.includes(extension);
const etag = strongEtag(filePath);
const etag = strongEtag(filePath, stat);
headers.ETag = etag;
if (rangeEnabled) headers["Accept-Ranges"] = contract.rangeRequests.unit;
if (matchesIfNoneMatch(request.headers["if-none-match"], etag)) {

View File

@@ -11,6 +11,7 @@
* <dir>/manifest.json
*
* Usage: node tools/web/generate-pbr-library-manifest.mjs [--dir <path>]
* [--library-id <id>] [--library-name <name>] [--source <virtual path>] [--blend <file>]
*/
import assert from "node:assert/strict";
import crypto from "node:crypto";
@@ -20,14 +21,16 @@ import { pathToFileURL } from "node:url";
import ts from "../../web/node_modules/typescript/lib/typescript.js";
const REPO = path.resolve(import.meta.dirname, "../..");
const dirIndex = process.argv.indexOf("--dir");
const DIR = dirIndex > 0 && process.argv[dirIndex + 1]
? path.resolve(process.argv[dirIndex + 1])
: path.join(REPO, "web/app/public/assets/pbr-library");
const flag = (name, fallback) => {
const index = process.argv.indexOf(`--${name}`);
return index > 0 && process.argv[index + 1] ? process.argv[index + 1] : fallback;
};
const DIR = path.resolve(flag("dir", path.join(REPO, "web/app/public/assets/pbr-library")));
const LIBRARY_ID = "library:pbr-essentials-v1";
const LIBRARY_NAME = "PBR Essentials";
const LIBRARY_SOURCE = "libraries/pbr-library.blend";
const LIBRARY_ID = flag("library-id", "library:pbr-essentials-v1");
const LIBRARY_NAME = flag("library-name", "PBR Essentials");
const LIBRARY_SOURCE = flag("source", "libraries/pbr-library.blend");
const BLEND_FILE = flag("blend", "pbr-library.blend");
const readJson = (name) => JSON.parse(fs.readFileSync(path.join(DIR, name), "utf8"));
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
@@ -51,7 +54,7 @@ async function loadProtocol() {
const desktop = readJson("desktop-canonical.json");
const previewManifest = readJson("preview-manifest.json");
const libraryBytes = fs.readFileSync(path.join(DIR, "pbr-library.blend"));
const libraryBytes = fs.readFileSync(path.join(DIR, BLEND_FILE));
const sourceSha256 = sha256(libraryBytes);
const previews = new Map();

View File

@@ -92,23 +92,19 @@ def add_wood_texture(material, principled):
pixels[index + 3] = 1.0
image = bpy.data.images.new("PBR Wood Grain", width=size, height=size, alpha=False)
image.pixels = pixels
image.filepath_raw = str(pathlib.Path(bpy.app.tempdir) / "pbr-wood-grain.png")
image.file_format = "PNG"
image.save()
image.pack()
pathlib.Path(image.filepath_raw).unlink(missing_ok=True)
image.name = "PBR Wood Grain"
nodes = material.node_tree.nodes
links = material.node_tree.links
uv = nodes.new("ShaderNodeUVMap")
uv.name = "PBR Wood UV"
uv.uv_map = "UVMap"
mapping = nodes.new("ShaderNodeMapping")
mapping.name = "PBR Wood Mapping"
mapping.inputs["Scale"].default_value = (2.0, 4.0, 1.0)
texture = nodes.new("ShaderNodeTexImage")
texture.name = "PBR Wood Image"
texture.image = image
texture.interpolation = "Linear"
links.new(uv.outputs["UV"], mapping.inputs["Vector"])
links.new(mapping.outputs["Vector"], texture.inputs["Vector"])
links.new(texture.outputs["Color"], principled.inputs["Base Color"])
set_input(principled, ["Roughness"], 0.52)
set_input(principled, ["Metallic"], 0.0)
@@ -145,8 +141,10 @@ def build_materials():
materials.append((plastic_white, CATALOG_BY_PATH["Materials/Plastic"], "Matte white plastic", ("plastic", "white")))
wood, p = new_material("PBR Wood")
add_wood_texture(wood, p)
materials.append((wood, CATALOG_BY_PATH["Materials/Wood"], "Varnished wood with packed grain", ("wood", "texture")))
set_input(p, ["Base Color"], (0.32, 0.16, 0.06, 1.0))
set_input(p, ["Metallic"], 0.0)
set_input(p, ["Roughness"], 0.52)
materials.append((wood, CATALOG_BY_PATH["Materials/Wood"], "Warm matte wood colour", ("wood", "warm")))
glass, p = new_material("PBR Glass")
set_input(p, ["Base Color"], (0.92, 0.96, 1.0, 1.0))
@@ -230,6 +228,13 @@ def build_model(kind, name, material, catalog_path):
obj.data.name = f"{name} Mesh"
bpy.ops.object.transform_apply(location=False, rotation=True, scale=True)
ensure_uv(obj)
bpy.ops.object.select_all(action="DESELECT")
obj.select_set(True)
bpy.context.view_layer.objects.active = obj
bpy.ops.object.mode_set(mode="EDIT")
bpy.ops.mesh.select_all(action="SELECT")
bpy.ops.mesh.normals_make_consistent(inside=False)
bpy.ops.object.mode_set(mode="OBJECT")
obj.data.materials.clear()
obj.data.materials.append(material)
obj.asset_mark()

View File

@@ -0,0 +1,130 @@
"""Render the 12 tutorial material/model combinations with desktop Blender.
Usage:
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
"""
import hashlib
import json
import pathlib
import sys
import bpy
MODELS = [
("PBR Suzanne", "suzanne"),
("PBR Cube", "cube"),
("PBR Torus", "torus"),
]
MATERIALS = [
("PBR Metal Brushed", "metal-brushed"),
("PBR Glass", "glass"),
("PBR Wood", "wood"),
("PBR Emission Cyan", "emission-cyan"),
]
def sha256_file(path):
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def append_library(blend_path):
with bpy.data.libraries.load(str(blend_path), link=False) as (data_from, data_to):
data_to.objects = list(data_from.objects)
data_to.materials = list(data_from.materials)
data_to.images = list(data_from.images)
return data_to.objects, data_to.materials
def main(studio_path, library_path, renders_dir):
studio = pathlib.Path(studio_path).resolve()
library = pathlib.Path(library_path).resolve()
out = pathlib.Path(renders_dir).resolve()
out.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.open_mainfile(filepath=str(studio))
scene = bpy.context.scene
objects, materials = append_library(library)
material_by_name = {material.name: material for material in materials if material}
model_by_name = {}
for obj in objects:
if obj is None:
continue
scene.collection.objects.link(obj)
model_by_name[obj.name] = obj
obj.hide_render = True
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 1024
scene.render.resolution_y = 1024
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
scene.render.image_settings.color_depth = "8"
scene.render.film_transparent = False
scene.render.dither_intensity = 0
scene.eevee.taa_render_samples = 64
scene.view_settings.look = "None"
manifest = []
for model_name, model_slug in MODELS:
model = model_by_name[model_name]
for material_name, material_slug in MATERIALS:
for other in model_by_name.values():
other.hide_render = other is not model
material = material_by_name.get(material_name)
if material is None:
raise RuntimeError(f"missing material {material_name}")
model.data.materials.clear()
model.data.materials.append(material)
png = out / f"{model_slug}-{material_slug}.png"
jpg = out / f"{model_slug}-{material_slug}.jpg"
scene.render.filepath = str(png)
bpy.ops.render.render(write_still=True)
scene.render.image_settings.file_format = "JPEG"
scene.render.image_settings.color_mode = "RGB"
scene.render.image_settings.quality = 90
scene.render.filepath = str(jpg)
bpy.ops.render.render(write_still=True)
scene.render.image_settings.file_format = "PNG"
scene.render.image_settings.color_mode = "RGBA"
manifest.append({
"model": model_name,
"modelSlug": model_slug,
"material": material_name,
"materialSlug": material_slug,
"png": str(png.relative_to(pathlib.Path(__file__).resolve().parents[2])).replace("\\", "/"),
"jpg": str(jpg.relative_to(pathlib.Path(__file__).resolve().parents[2])).replace("\\", "/"),
"width": 1024,
"height": 1024,
"bytes": png.stat().st_size,
"sha256": sha256_file(png),
})
(out / "manifest.json").write_text(
json.dumps({
"schemaVersion": 1,
"renderer": "DESKTOP_BLENDER_EEVEE",
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
"renders": manifest,
}, indent=2, ensure_ascii=True) + "\n",
encoding="utf-8",
newline="\n",
)
print(json.dumps({"renders": len(manifest), "output": str(out)}))
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(arguments) != 3:
raise SystemExit("usage: blender --background --factory-startup --python generate-pbr-tutorial-renders.py -- STUDIO LIBRARY RENDERS")
main(*arguments)

View File

@@ -0,0 +1,161 @@
"""Generate the PBR tutorial studio scene with desktop Blender.
Usage:
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
"""
import hashlib
import json
import pathlib
import sys
import bpy
from mathutils import Vector
def sha256_file(path: pathlib.Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def add_area_light(name, location, energy, size):
# Point lights match the bounded three.js PBR renderer without needing
# RectAreaLight uniform support; the original size becomes a soft falloff.
bpy.ops.object.light_add(type="POINT", location=location)
light = bpy.context.object
light.name = name
light.data.energy = energy
light.data.shadow_soft_size = size * 0.5
light.rotation_euler = (-Vector(location)).to_track_quat("-Z", "Y").to_euler()
return light
def main(output_blend, report_path):
output = pathlib.Path(output_blend).resolve()
report = pathlib.Path(report_path).resolve()
output.parent.mkdir(parents=True, exist_ok=True)
report.parent.mkdir(parents=True, exist_ok=True)
bpy.ops.wm.read_factory_settings(use_empty=True)
scene = bpy.context.scene
scene.name = "PBR Studio"
bpy.ops.object.camera_add(location=(7.2, -7.2, 5.4))
camera = bpy.context.object
camera.name = "PBR Studio Camera"
camera.data.name = "PBR Studio Camera"
camera.data.type = "PERSP"
camera.data.lens = 55
camera.data.sensor_width = 36
camera.data.sensor_fit = "HORIZONTAL"
camera.data.clip_start = 0.01
camera.data.clip_end = 1000
camera.rotation_euler = (-camera.location).to_track_quat("-Z", "Y").to_euler()
scene.camera = camera
key = add_area_light("PBR Studio Key", (3.2, -3.2, 4.0), 900, 4.0)
fill = add_area_light("PBR Studio Fill", (-4.0, -2.0, 2.0), 500, 5.0)
rim = add_area_light("PBR Studio Rim", (0.0, 4.0, 3.0), 700, 3.0)
bpy.ops.object.light_add(type="SUN", location=(4.0, -4.0, 6.0))
sun = bpy.context.object
sun.name = "PBR Studio Sun"
sun.data.energy = 30.0
sun.data.angle = 0.15
sun.rotation_euler = (-sun.location).to_track_quat("-Z", "Y").to_euler()
world = bpy.data.worlds.new("PBR Studio World")
world.use_nodes = True
background = world.node_tree.nodes.get("Background")
background.inputs["Color"].default_value = (0.045, 0.05, 0.06, 1.0)
background.inputs["Strength"].default_value = 1.4
# Packed equirectangular studio environment: point lights alone leave
# metallic materials almost black in the bounded MeshPhysicalMaterial path.
width, height = 512, 256
pixels = [0.0] * (width * height * 4)
for y in range(height):
v = y / (height - 1)
base = 0.35 + 0.65 * (v ** 1.2)
for x in range(width):
u = x / (width - 1)
glow = 0.0
for cx, cy, radius, strength in (
(0.20, 0.78, 0.12, 5.0),
(0.52, 0.85, 0.10, 4.0),
(0.82, 0.70, 0.13, 3.0),
):
dx = (u - cx) * 2.0
dy = (v - cy) * 1.4
glow += strength * max(0.0, 1.0 - (dx * dx + dy * dy) / (radius * radius))
value = min(8.0, base + glow)
index = (y * width + x) * 4
pixels[index] = value
pixels[index + 1] = value * 1.02
pixels[index + 2] = value * 1.06
pixels[index + 3] = 1.0
environment = bpy.data.images.new("PBR Studio Environment", width=width, height=height, alpha=False)
environment.pixels = pixels
environment.filepath_raw = str(output.parent / "_studio-environment.png")
environment.file_format = "PNG"
environment.save()
environment.pack()
pathlib.Path(environment.filepath_raw).unlink(missing_ok=True)
environment_node = world.node_tree.nodes.new("ShaderNodeTexEnvironment")
environment_node.name = "PBR Studio Environment"
environment_node.image = environment
world.node_tree.links.new(environment_node.outputs["Color"], background.inputs["Color"])
scene.world = world
bpy.ops.mesh.primitive_plane_add(size=20.0, location=(0.0, 0.0, -1.0))
ground = bpy.context.object
ground.name = "PBR Studio Ground"
ground.data.name = "PBR Studio Ground Mesh"
material = bpy.data.materials.new("PBR Studio Ground Material")
material.use_nodes = True
principled = material.node_tree.nodes.get("Principled BSDF")
principled.inputs["Base Color"].default_value = (0.32, 0.32, 0.33, 1.0)
principled.inputs["Roughness"].default_value = 0.65
principled.inputs["Metallic"].default_value = 0.0
ground.data.materials.append(material)
scene.render.engine = "BLENDER_EEVEE"
scene.render.resolution_x = 1024
scene.render.resolution_y = 1024
scene.render.resolution_percentage = 100
scene.render.image_settings.file_format = "PNG"
scene.view_settings.look = "None"
bpy.ops.wm.save_as_mainfile(filepath=str(output), compress=True)
lights = [light.name for light in (key, fill, rim, sun)]
payload = {
"schemaVersion": 1,
"generator": "tools/web/generate-pbr-tutorial-studio.py",
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
"blenderExecutable": bpy.app.binary_path,
"blend": {
"path": str(output),
"sha256": sha256_file(output),
"byteLength": output.stat().st_size,
},
"objects": sorted(object.name for object in scene.objects),
"camera": camera.name,
"lights": lights,
"world": world.name,
"ground": ground.name,
}
report.write_text(json.dumps(payload, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n")
print(json.dumps({"studio": str(output), "sha256": payload["blend"]["sha256"], "objects": payload["objects"]}))
if __name__ == "__main__":
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(arguments) != 2:
raise SystemExit("usage: blender --background --factory-startup --python generate-pbr-tutorial-studio.py -- BLEND REPORT")
main(arguments[0], arguments[1])

View File

@@ -0,0 +1,329 @@
"""Derive the Web Blender RE 5000PBR asset library from the desktop catalog.
Usage:
build_blender_native/bin/blender --background --factory-startup \
--python tools/web/generate-re-pbr-library.py -- \
"/path/to/5000+ PBR catalog.blend" OUTPUT_DIRECTORY \
[--base-color-size 1024] [--normal-size 1024] [--scalar-size 512] [--preview-size 256]
The source .blend is never modified. The derived library keeps the original
shader graphs (FRAME/REROUTE/DISPLACEMENT/MAPPING/VALUE included) so the Web
compiler/reader must support them; this script only marks assets, downsamples
packed textures, repacks them and renders material previews.
Outputs (inside OUTPUT_DIRECTORY):
re-pbr-library.blend six marked MATERIAL assets, no scene objects
blender_assets.cats.txt Materials/RE5000PBR catalog
previews/<sha256>.png 256x256 EEVEE material previews
preview-manifest.json
build-report.json
"""
import hashlib
import importlib.util
import json
import pathlib
import sys
import bpy
CATALOG_ID = "a8000000-0000-4000-8000-000000000001"
CATALOG_PATH = "Materials/RE5000PBR"
CATALOG_NAME = "RE5000PBR"
LIBRARY_NAME = "re-pbr-library.blend"
EXPECTED_MATERIALS = 6
def sha256_file(path: pathlib.Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def load_common():
path = pathlib.Path(__file__).with_name("generate-pbr-library.py")
spec = importlib.util.spec_from_file_location("pbr_library_common", path)
if spec is None or spec.loader is None:
raise RuntimeError(f"cannot load {path}")
module = importlib.util.module_from_spec(spec)
spec.loader.exec_module(module)
return module
def parse_arguments():
arguments = sys.argv[sys.argv.index("--") + 1:] if "--" in sys.argv else []
if len(arguments) < 2:
raise SystemExit(
"usage: blender -b --python generate-re-pbr-library.py -- "
"SOURCE_BLEND OUTPUT_DIR [--base-color-size N] [--normal-size N] "
"[--scalar-size N] [--preview-size N]"
)
source = pathlib.Path(arguments[0]).resolve()
output = pathlib.Path(arguments[1]).resolve()
options = {
"base_color_size": 1024,
"normal_size": 1024,
"scalar_size": 512,
"preview_size": 256,
"preserve_original": False,
"material_suffix": "",
"library_id": "library:re-pbr-v1",
}
index = 2
while index < len(arguments):
flag = arguments[index]
if flag == "--preserve-original":
options["preserve_original"] = True
index += 1
continue
if flag not in {"--base-color-size", "--normal-size", "--scalar-size", "--preview-size", "--texture-size", "--material-suffix", "--library-id"}:
raise SystemExit(f"unknown option {flag}")
if index + 1 >= len(arguments):
raise SystemExit(f"missing value for {flag}")
key = flag.removeprefix("--").replace("-", "_")
value = arguments[index + 1]
if key == "texture_size":
size = int(value)
options["base_color_size"] = size
options["normal_size"] = size
options["scalar_size"] = size
elif key in {"base_color_size", "normal_size", "scalar_size", "preview_size"}:
options[key] = int(value)
else:
options[key] = value
index += 2
if not source.is_file():
raise SystemExit(f"source blend not found: {source}")
return source, output, options
def target_size(image_name: str, options) -> int:
lowered = image_name.lower()
if "normal" in lowered:
return options["normal_size"]
if "base_color" in lowered or "basecolor" in lowered or "albedo" in lowered:
return options["base_color_size"]
return options["scalar_size"]
def resample_image(image, size: int, temporary: pathlib.Path, preserve_original: bool = False):
if image.size[0] == 0 or image.size[1] == 0:
return
if preserve_original and image.size[0] == size and image.size[1] == size:
return
if image.size[0] != size or image.size[1] != size:
image.scale(size, size)
image.file_format = "JPEG"
temporary.parent.mkdir(parents=True, exist_ok=True)
try:
image.save(filepath=str(temporary), quality=84)
except TypeError:
image.filepath_raw = str(temporary)
image.save()
if not temporary.is_file() or temporary.stat().st_size == 0:
raise RuntimeError(f"failed to write resampled image {temporary}")
data = temporary.read_bytes()
# Image.pack() with no data re-packs the original packed file. Pack the
# resampled JPEG bytes explicitly so the library actually shrinks.
image.pack(data=data, data_len=len(data))
def material_uses_principled(material) -> bool:
if not material.use_nodes or material.node_tree is None:
return False
return any(node.type == "BSDF_PRINCIPLED" for node in material.node_tree.nodes)
def collect_materials():
materials = [
material for material in bpy.data.materials
if material.name != "Dots Stroke" and material_uses_principled(material)
]
materials.sort(key=lambda material: material.name)
if len(materials) != EXPECTED_MATERIALS:
raise RuntimeError(
f"expected {EXPECTED_MATERIALS} Principled materials, found {len(materials)}: "
+ ", ".join(material.name for material in materials)
)
return materials
def write_catalog_definition(path: pathlib.Path):
lines = [
"# This is an Asset Catalog Definition file for Blender.",
"# Derived from the desktop RE 5000PBR catalog by generate-re-pbr-library.py",
"",
"VERSION 1",
"",
f"{CATALOG_ID}:{CATALOG_PATH}:{CATALOG_NAME}",
]
path.write_text("\n".join(lines) + "\n", encoding="utf-8", newline="\n")
def mark_materials(materials):
for material in materials:
if material.asset_data is None:
material.asset_mark()
material.asset_data.catalog_id = CATALOG_ID
material.asset_data.description = "RE 5000PBR material for the Web Blender render lesson"
material.asset_data.tags.new("PBR")
material.asset_data.tags.new("RE5000PBR")
def resample_material_images(materials, options, temporary_dir: pathlib.Path):
processed = set()
resampled = []
for material in materials:
if material.node_tree is None:
continue
for node in material.node_tree.nodes:
if node.type != "TEX_IMAGE" or node.image is None:
continue
image = node.image
if image.name in processed or not image.packed_file:
continue
processed.add(image.name)
size = target_size(image.name, options)
temporary = temporary_dir / f"{sha256_bytes(image.name.encode('utf-8'))[:16]}.jpg"
before = image.size[:]
colorspace = image.colorspace_settings.name
resample_image(image, size, temporary, options.get("preserve_original", False))
image.colorspace_settings.name = colorspace
resampled.append({
"name": image.name,
"from": list(before),
"to": list(image.size[:]),
"colorspace": colorspace,
"packedBytes": image.packed_file.size if image.packed_file else 0,
})
return resampled
def remove_scene_objects():
for obj in list(bpy.data.objects):
bpy.data.objects.remove(obj, do_unlink=True)
def remove_unused_data(materials):
keep_material = {material.name for material in materials}
for material in list(bpy.data.materials):
if material.name not in keep_material:
bpy.data.materials.remove(material)
used_images = set()
for material in materials:
if material.node_tree is None:
continue
for node in material.node_tree.nodes:
if node.type == "TEX_IMAGE" and node.image is not None:
used_images.add(node.image.name)
for image in list(bpy.data.images):
if image.name not in used_images and not image.name.startswith("Render Result"):
bpy.data.images.remove(image)
def render_previews(common, materials, preview_dir: pathlib.Path, preview_size: int):
common.PREVIEW_SIZE = preview_size
scene = bpy.context.scene
camera = common.configure_preview_scene(scene)
scene.render.resolution_x = preview_size
scene.render.resolution_y = preview_size
entries = []
for material in materials:
bpy.ops.mesh.primitive_uv_sphere_add(segments=96, ring_count=64, radius=1.0, location=(0.0, 0.0, 0.0))
sphere = bpy.context.object
sphere.name = f"Preview {material.name}"
bpy.ops.object.shade_smooth()
sphere.data.materials.append(material)
common.frame_object(scene, camera, sphere)
temporary = preview_dir / "tmp.png"
common.render_preview(scene, temporary)
entry = common.store_preview(temporary, preview_dir)
bpy.data.objects.remove(sphere, do_unlink=True)
entry.update({"idType": "MATERIAL", "name": material.name})
entries.append(entry)
return entries
def main():
source, output, options = parse_arguments()
output.mkdir(parents=True, exist_ok=True)
preview_dir = output / "previews"
preview_dir.mkdir(parents=True, exist_ok=True)
for stale in preview_dir.glob("*.png"):
stale.unlink()
common = load_common()
bpy.ops.wm.open_mainfile(filepath=str(source))
materials = collect_materials()
suffix = str(options.get("material_suffix", ""))
if suffix:
for material in materials:
material.name = f"{material.name}{suffix}"
mark_materials(materials)
temporary_dir = output / ".resample"
resampled = resample_material_images(materials, options, temporary_dir)
remove_scene_objects()
remove_unused_data(materials)
catalog_path = output / "blender_assets.cats.txt"
write_catalog_definition(catalog_path)
library_path = output / LIBRARY_NAME
bpy.ops.wm.save_as_mainfile(filepath=str(library_path), compress=True)
preview_entries = render_previews(common, materials, preview_dir, options["preview_size"])
preview_manifest = output / "preview-manifest.json"
preview_manifest.write_text(
json.dumps({"schemaVersion": 1, "previews": preview_entries}, indent=2, ensure_ascii=True) + "\n",
encoding="utf-8",
newline="\n",
)
for item in temporary_dir.glob("*"):
if item.is_file():
item.unlink()
if temporary_dir.is_dir():
temporary_dir.rmdir()
source_sha = sha256_file(source)
library_sha = sha256_file(library_path)
preview_bytes = sum(entry["byteLength"] for entry in preview_entries)
report = {
"schemaVersion": 1,
"generator": "tools/web/generate-re-pbr-library.py",
"scriptSha256": sha256_file(pathlib.Path(__file__).resolve()),
"source": {"path": str(source), "sha256": source_sha, "byteLength": source.stat().st_size},
"library": {
"path": str(library_path),
"sha256": library_sha,
"byteLength": library_path.stat().st_size,
"libraryId": "library:re-pbr-v1",
"name": LIBRARY_NAME,
},
"catalog": {"id": CATALOG_ID, "path": CATALOG_PATH, "name": CATALOG_NAME},
"materials": [material.name for material in materials],
"textures": resampled,
"textureOptions": options,
"previewCount": len(preview_entries),
"previewBytes": preview_bytes,
"blenderVersion": ".".join(str(value) for value in bpy.app.version),
}
(output / "build-report.json").write_text(
json.dumps(report, indent=2, ensure_ascii=True) + "\n", encoding="utf-8", newline="\n"
)
print(json.dumps({
"materials": len(materials),
"previews": len(preview_entries),
"librarySha256": library_sha,
"libraryBytes": library_path.stat().st_size,
"textureBytes": sum(item["packedBytes"] for item in resampled),
}, indent=2))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,184 @@
#!/usr/bin/env python3
"""Prepare Chinese narration and mix it into the RE 5000PBR lesson video.
Usage:
python3 generator.py --mode prepare \
--chapters /home/.../tools/web/re-pbr-video-chapters.json \
--out /home/.../tests/golden/RE-PBR-VIDEO-001
python3 generator.py --mode mix \
--out /home/.../tests/golden/RE-PBR-VIDEO-001 \
--video /home/.../web/app/public/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4
"""
import argparse
import hashlib
import json
import pathlib
import re
import subprocess
import wave
EDGE_TTS = pathlib.Path("/home/mes123456/workinf_Blender_Wasm/tools/web/.tts-venv/bin/edge-tts")
FFMPEG = pathlib.Path("/usr/bin/ffmpeg")
FFPROBE = pathlib.Path("/usr/bin/ffprobe")
SAMPLE_RATE = 48000
SAMPLE_WIDTH = 2
CHANNELS = 1
def run(command, **kwargs):
return subprocess.run(command, check=True, capture_output=True, text=True, **kwargs)
def sha256(path):
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def prepare(chapters_path, output):
chapters = json.loads(chapters_path.read_text(encoding="utf-8"))
voice = chapters.get("voice", "zh-CN-XiaoxiaoNeural")
audio_dir = output / "narration"
audio_dir.mkdir(parents=True, exist_ok=True)
prepared = []
for chapter in chapters["chapters"]:
chapter_id = chapter["id"]
mp3 = audio_dir / f"{chapter_id}.mp3"
wav = audio_dir / f"{chapter_id}.wav"
if not mp3.exists():
run([str(EDGE_TTS), "--voice", voice, "--text", chapter["narration"], "--write-media", str(mp3)])
if not wav.exists() or wav.stat().st_mtime < mp3.stat().st_mtime:
run([str(FFMPEG), "-y", "-hide_banner", "-loglevel", "error", "-i", str(mp3),
"-ar", str(SAMPLE_RATE), "-ac", str(CHANNELS), "-c:a", "pcm_s16le", str(wav)])
duration = float(run([str(FFPROBE), "-v", "error", "-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", str(wav)]).stdout.strip())
prepared.append({
"id": chapter_id,
"title": chapter["title"],
"narration": chapter["narration"],
"mp3": str(mp3),
"wav": str(wav),
"durationMs": int(round(duration * 1000)),
})
result = {"voice": voice, "chapters": prepared}
(output / "narration.json").write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
total = sum(item["durationMs"] for item in prepared)
print(json.dumps({"status": "PREPARED", "voice": voice, "chapters": len(prepared), "narrationMs": total}, ensure_ascii=False))
def read_pcm(path):
with wave.open(str(path), "rb") as handle:
if handle.getnchannels() != CHANNELS or handle.getsampwidth() != SAMPLE_WIDTH or handle.getframerate() != SAMPLE_RATE:
raise SystemExit(f"unexpected WAV format: {path}")
return handle.readframes(handle.getnframes())
def overlay(buffer, samples, offset_samples):
for index in range(0, len(samples), 2):
target = (offset_samples * 2) + index
if target + 1 >= len(buffer):
break
left = int.from_bytes(buffer[target:target + 2], "little", signed=True)
right = int.from_bytes(samples[index:index + 2], "little", signed=True)
mixed = max(-32768, min(32767, left + right))
buffer[target:target + 2] = mixed.to_bytes(2, "little", signed=True)
def timestamp(milliseconds):
seconds, ms = divmod(max(0, int(milliseconds)), 1000)
minutes, seconds = divmod(seconds, 60)
hours, minutes = divmod(minutes, 60)
return f"{hours:02d}:{minutes:02d}:{seconds:02d}.{ms:03d}"
def mix(output, video):
timeline_path = output / "timeline.json"
narration_path = output / "narration.json"
if not timeline_path.exists():
raise SystemExit(f"missing recording timeline: {timeline_path}")
if not narration_path.exists():
raise SystemExit(f"missing narration: {narration_path}")
timeline = json.loads(timeline_path.read_text(encoding="utf-8"))
narration = json.loads(narration_path.read_text(encoding="utf-8"))
by_id = {chapter["id"]: chapter for chapter in narration["chapters"]}
total_ms = int(timeline.get("durationMs", 0))
for chapter in timeline.get("chapters", []):
total_ms = max(total_ms, int(chapter.get("endMs", 0)) + 800)
total_samples = int(total_ms / 1000 * SAMPLE_RATE)
buffer = bytearray(total_samples * 2)
cues = []
for chapter in timeline.get("chapters", []):
prepared = by_id.get(chapter["id"])
if not prepared:
continue
start_ms = int(chapter.get("startMs", 0))
samples = read_pcm(pathlib.Path(prepared["wav"]))
overlay(buffer, samples, int(start_ms / 1000 * SAMPLE_RATE))
cues.append({
"id": chapter["id"],
"title": prepared["title"],
"startMs": start_ms,
"endMs": start_ms + prepared["durationMs"],
"narration": prepared["narration"],
})
narration_wav = output / "narration.wav"
with wave.open(str(narration_wav), "wb") as handle:
handle.setnchannels(CHANNELS)
handle.setsampwidth(SAMPLE_WIDTH)
handle.setframerate(SAMPLE_RATE)
handle.writeframes(bytes(buffer))
vtt = ["WEBVTT", ""]
for cue in cues:
vtt.extend([f"{timestamp(cue['startMs'])} --> {timestamp(cue['endMs'])}", f"{cue['title']}:{cue['narration']}", ""])
vtt_path = video.parent / "re-pbr-lesson.zh-Hans.vtt"
vtt_path.write_text("\n".join(vtt), encoding="utf-8")
video.parent.mkdir(parents=True, exist_ok=True)
run([str(FFMPEG), "-y", "-hide_banner", "-loglevel", "error", "-i", str(output / "screencast.webm"),
"-i", str(narration_wav), "-vf", "scale=1280:720", "-c:v", "libx264", "-crf", "23", "-preset", "medium",
"-c:a", "aac", "-b:a", "128k", "-movflags", "+faststart", "-shortest", str(video)])
poster = video.parent / "poster.jpg"
poster_ms = 1000
for chapter in timeline.get("chapters", []):
if chapter.get("id") == "hq-2048":
poster_ms = max(1000, min(int(chapter.get("endMs", 0)) - 8000,
int(timeline.get("durationMs", 0)) - 2000))
run([str(FFMPEG), "-y", "-hide_banner", "-loglevel", "error", "-ss", f"{poster_ms / 1000:.3f}", "-i", str(video),
"-frames:v", "1", "-vf", "scale=1280:720", str(poster)])
probe = run([str(FFPROBE), "-v", "error", "-show_entries",
"format=duration,size:stream=codec_type,codec_name,width,height", "-of", "json", str(video)])
(output / "video-probe.json").write_text(probe.stdout + "\n", encoding="utf-8")
report = {
"schemaVersion": 1,
"status": "PASS",
"voice": narration.get("voice"),
"video": {"path": str(video), "sha256": sha256(video), "bytes": video.stat().st_size},
"narration": {"path": str(narration_wav), "sha256": sha256(narration_wav), "bytes": narration_wav.stat().st_size},
"vtt": {"path": str(vtt_path), "sha256": sha256(vtt_path), "cues": len(cues)},
"poster": {"path": str(poster), "sha256": sha256(poster)},
"chapters": cues,
}
(output / "video-report.json").write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(json.dumps({"status": "MIXED", "video": str(video), "bytes": video.stat().st_size,
"duration": json.loads(probe.stdout).get("format", {}).get("duration"), "cues": len(cues)}, ensure_ascii=False))
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--mode", choices=["prepare", "mix"], required=True)
parser.add_argument("--chapters", default="/home/mes123456/workinf_Blender_Wasm/tools/web/re-pbr-video-chapters.json")
parser.add_argument("--out", default="/home/mes123456/workinf_Blender_Wasm/tests/golden/RE-PBR-VIDEO-001")
parser.add_argument("--video", default="/home/mes123456/workinf_Blender_Wasm/web/app/public/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4")
args = parser.parse_args()
output = pathlib.Path(args.out)
output.mkdir(parents=True, exist_ok=True)
if args.mode == "prepare":
prepare(pathlib.Path(args.chapters), output)
else:
mix(output, pathlib.Path(args.video))
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,71 @@
{
"schemaVersion": 1,
"voice": "zh-CN-XiaoxiaoNeural",
"chapters": [
{
"id": "intro",
"title": "开场:会得到什么",
"narration": "这一课我们从灰色猴头开始。你会打开摄影棚,导入只有一个猴头网格的 GLB 文件,从 RE 五零零零 PBR 资产库分配六种材质,最后在浏览器里用显卡渲染、下载 PNG、保存工程并重新打开。"
},
{
"id": "open-studio",
"title": "载入摄影棚",
"narration": "先打开 Web Blender 的 Rendering 工作区。在 PBR 渲染练习面板里点击载入练习摄影棚。这个场景已经准备好相机、三点面光、太阳光、世界环境和地面。等待状态栏显示 Engine SceneIR,并且对象数是六。"
},
{
"id": "import-glb",
"title": "导入猴头 GLB",
"narration": "接下来点击导入猴头 GLB,选择课程目录里的 monkey head 点 GLB。导入会追加到当前场景,不会替换摄影棚。看到对象数从六变成七,活动对象是 Suzanne 就对了。此时猴头没有材质,所以是灰色的,这是正常的基线。"
},
{
"id": "asset-library",
"title": "认识 Shading 与 RE 资产库",
"narration": "切换到 Shading 工作区。左下角是 PBR 资产库,现在有二十个资产。用资产来源下拉框选择 RE 五零零零 PBR,只留下六张材质卡片。单击卡片只会选中,双击才会把材质分配出去。缩略图加载完成后就可以继续。"
},
{
"id": "assign-materials",
"title": "分配六种材质",
"narration": "回到 Rendering 工作区。在 PBR 渲染练习面板下方,新加了 RE 五零零零 PBR 材质选择器,它显示完整材质名和缩略图,不受左侧窄栏影响。依次双击六种材质:防滑颗粒、砖墙、随机瓷砖、光亮瓷砖、规则砖墙和做旧砖墙。每次等待材质槽更新,并确认材质球显示 COMPILED。"
},
{
"id": "render-settings",
"title": "设置渲染",
"narration": "现在设置渲染。分辨率选一零二四,相机选场景相机,视图保持最终材质,教学曝光保持零,位移强度保持一倍,视角预设选场景相机对齐。这样得到的就是固定摄影棚下的标准成片。"
},
{
"id": "render-download",
"title": "渲染与下载 PNG",
"narration": "点击渲染 PBR。面板会先等待纹理和环境贴图就绪,然后输出一张一零二四乘一零二四的 WebGL2 成片。结果出现后,点击下载 PNG,浏览器会保存 pbr 一零二四点 PNG。"
},
{
"id": "save-reopen",
"title": "保存与重开",
"narration": "点击顶部的保存项目,下载点 blend 工程。然后再次导入刚才保存的工程;如果询问是否保存当前修改,选择不保存并继续。重新打开后对象数仍是七,材质槽仍然保留做旧砖墙,再渲染一次确认贴图没有丢失。"
},
{
"id": "diagnostics",
"title": "诊断视图与教学曝光",
"narration": "深色材质只看整体亮度容易误判。把视图切到 Base Color,可以看到基础色贴图;切到 Normal,可以看到法线和砖缝;切到 Roughness,可以看到粗糙度。教学曝光可以加一档或两档 EV,只改变渲染曝光,不改变材质本身。"
},
{
"id": "displacement",
"title": "位移与细分",
"narration": "Height 贴图现在会真正参与几何位移。位移强度一倍保留源材质零点零零一的 Scale,画面变化很细;切到一百倍只是渲染时的教学放大,不写回材质。静帧会把九百六十八个三角形细分到六万一千九百五十二个,再执行位移。"
},
{
"id": "camera",
"title": "对比视角",
"narration": "视角预设默认与场景相机精确对齐,所以场景和视口可能看起来一样。选择对比四十五度后,当前视口会以主体为中心偏转三十度,得到明显不同的构图;主视口导航不受影响。"
},
{
"id": "hq-2048",
"title": "高清 2048 贴图",
"narration": "如果需要原始分辨率,点击加载高清贴图二零四八。素材会按需合并,材质名带 HQ 后缀。切换到高清来源,双击分配做旧砖墙 HQ,再渲染一次。五个纹理全部加载,显存预算约六十四点五兆,低于五百一十二兆上限。"
},
{
"id": "summary",
"title": "总结与教案",
"narration": "到这里,你已经完成从灰色猴头到六种 PBR 材质的完整流程:打开场景、导入 GLB、分配材质、渲染下载、保存重开,以及诊断、位移、对比相机和高清贴图。更多截图和文字步骤在渲染图文教案里,地址是 tutorial pbr render lesson 点 HTML。"
}
]
}

View File

@@ -0,0 +1,470 @@
#!/usr/bin/env node
/**
* Headed Chromium recording for the RE 5000PBR lesson video.
*
* Usage:
* DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \
* node tools/web/record-re-pbr-lesson-video.mjs --dev --port 5184 --gpu hardware \
* --out tests/golden/RE-PBR-VIDEO-001
*
* Prepare narration first:
* python3 -I tools/web/generate-re-pbr-narration.py --mode prepare
*/
import { spawn } from "node:child_process";
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import { createRequire } from "node:module";
import { fileURLToPath } from "node:url";
const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
const WEB = path.join(REPO, "web");
const requireFromWeb = createRequire(path.join(WEB, "package.json"));
const { chromium } = requireFromWeb("playwright");
const LESSON = path.join(REPO, "web/app/public/tutorial/pbr/lesson");
const STUDIO = path.join(REPO, "web/app/public/tutorial/pbr/studio.blend");
const GLB = path.join(REPO, "re/猴头.glb");
const MATERIALS = [
"Black_Anti_Slip_grip",
"Blackish_Brick_Wall_016",
"black_randome_tile",
"black_shinning_tile",
"black_tiles_wall",
"black_worn_wall_brick",
];
const args = process.argv.slice(2);
const flag = (name, fallback) => {
const index = args.indexOf(`--${name}`);
return index >= 0 && args[index + 1] ? args[index + 1] : fallback;
};
const useDev = args.includes("--dev");
const smoke = args.includes("--smoke");
const port = Number.parseInt(flag("port", "5184"), 10) || 5184;
const baseUrl = `http://127.0.0.1:${port}`;
const OUT = path.resolve(flag("out", process.env.RE_PBR_VIDEO_OUT ?? path.join(REPO, "tests/golden/RE-PBR-VIDEO-001")));
const NARRATION = path.join(OUT, "narration.json");
const gpuMode = flag("gpu", process.env.GPU_MODE ?? (process.env.DISPLAY ? "hardware" : "swiftshader"));
const hardwareArgs = [
"--no-sandbox", "--use-gl=angle", "--use-angle=gl", "--ignore-gpu-blocklist",
"--enable-gpu-rasterization", "--enable-zero-copy", "--enable-unsafe-webgpu",
"--disable-background-timer-throttling", "--disable-backgrounding-occluded-windows",
"--disable-renderer-backgrounding", "--hide-scrollbars",
];
const swiftshaderArgs = [
"--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader",
"--enable-unsafe-webgpu", "--disable-background-timer-throttling",
"--disable-backgrounding-occluded-windows", "--disable-renderer-backgrounding", "--hide-scrollbars",
];
fs.mkdirSync(OUT, { recursive: true });
const DIAG = path.join(OUT, "diagnostics");
fs.mkdirSync(DIAG, { recursive: true });
fs.mkdirSync(path.join(LESSON, "video"), { recursive: true });
fs.mkdirSync(path.join(LESSON, "video-steps"), { recursive: true });
// Preserve prior raw Playwright recordings for diagnosis; only clear the mixed target.
fs.rmSync(path.join(OUT, "screencast.webm"), { force: true });
const sha256 = (data) => crypto.createHash("sha256").update(data).digest("hex");
const fileSha = (file) => sha256(fs.readFileSync(file));
const narration = JSON.parse(fs.readFileSync(NARRATION, "utf8"));
const durationOf = (id) => narration.chapters.find((chapter) => chapter.id === id)?.durationMs ?? 6000;
let serverExit = null;
async function waitForServer(url, timeoutMs = 60_000) {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (serverExit) throw new Error(`server exited before becoming ready: ${JSON.stringify(serverExit)}`);
try { const response = await fetch(url); if (response.status < 500) return; } catch { /* retry */ }
await new Promise((resolve) => setTimeout(resolve, 400));
}
throw new Error(`server did not start: ${url}`);
}
function track(child) {
child.on("exit", (code, signal) => { serverExit = { code, signal }; });
child.stdout.on("data", () => undefined); child.stderr.on("data", () => undefined);
return child;
}
function startServer() {
if (useDev) {
return track(spawn(process.execPath, [path.join(WEB, "node_modules/vite/bin/vite.js"),
"--config", "app/vite.config.ts", "--host", "127.0.0.1", "--port", String(port), "--strictPort"],
{ cwd: WEB, stdio: ["ignore", "pipe", "pipe"] }));
}
return track(spawn(process.execPath, [path.join(REPO, "tools/web/deployment-http-server.mjs"), "dist"],
{ cwd: WEB, stdio: ["ignore", "pipe", "pipe"], env: { ...process.env, BLENDER_WEB_HTTP_PORT: String(port) } }));
}
async function installOverlay(page) {
await page.evaluate(() => {
if (!document.querySelector('link[data-lesson-video-overlay]')) {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = "/tutorial/pbr/lesson/video-overlay.css";
link.dataset.lessonVideoOverlay = "";
document.head.append(link);
}
if (document.getElementById("__lesson_video_overlay")) return;
const root = document.createElement("div");
root.id = "__lesson_video_overlay";
const cursor = document.createElement("div");
cursor.id = "__lesson_video_cursor";
const caption = document.createElement("div");
caption.id = "__lesson_video_caption";
caption.textContent = "Web Blender · RE 5000PBR 渲染课";
root.append(cursor, caption);
document.body.append(root);
window.__lessonSetCaption = (text) => { caption.textContent = text; };
window.addEventListener("mousemove", (event) => {
cursor.getAnimations().forEach((animation) => animation.cancel());
cursor.animate([{ transform: `translate(${event.clientX}px, ${event.clientY}px)` }], { duration: 90, fill: "forwards", easing: "linear" });
}, { passive: true });
window.addEventListener("mousedown", (event) => {
const ripple = document.createElement("div");
ripple.className = "__lesson_video_ripple";
root.append(ripple);
ripple.animate([
{ transform: `translate(${event.clientX}px, ${event.clientY}px) scale(1)`, opacity: 0.95 },
{ transform: `translate(${event.clientX}px, ${event.clientY}px) scale(4.2)`, opacity: 0 },
], { duration: 520, easing: "ease-out" }).finished.then(() => ripple.remove());
}, { passive: true });
});
}
async function hideOverlay(page, hidden) {
await page.evaluate((value) => {
const root = document.getElementById("__lesson_video_overlay");
if (root) root.hidden = value;
}, hidden);
}
async function moveClick(page, locator) {
await locator.scrollIntoViewIfNeeded().catch(() => undefined);
const box = await locator.boundingBox().catch(() => null);
if (!box) { await locator.click({ force: true }); return; }
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2, { steps: 14 });
await page.waitForTimeout(120);
await locator.click({ force: true });
}
async function main() {
const server = startServer();
const chromePath = process.env.CHROME_PATH ?? "/usr/bin/google-chrome";
const errors = [];
const timeline = [];
const recordingStart = Date.now();
const consoleLog = path.join(DIAG, `console-${recordingStart}.log`);
let browser;
let context;
let page;
let video;
let probe = null;
let fatalError = null;
try {
await waitForServer(`${baseUrl}/`);
browser = await chromium.launch({
headless: false, executablePath: chromePath,
args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs,
});
context = await browser.newContext({
viewport: { width: 1600, height: 900 },
acceptDownloads: true,
recordVideo: { dir: OUT, size: { width: 1600, height: 900 } },
});
page = await context.newPage();
video = page.video();
page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`));
page.on("crash", () => errors.push("page crashed"));
page.on("console", (message) => {
const text = message.text();
fs.appendFileSync(consoleLog, `${new Date().toISOString()} ${message.type()} ${text}\n`);
if (message.type() !== "error") return;
const location = message.location();
if (/favicon/i.test(location.url) || /Failed to load resource.*404/i.test(text)) return;
if (!/Content Security Policy|style-src/i.test(text)) errors.push(`console: ${text}`);
});
await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" });
await page.locator('main.blender-app').waitFor({ timeout: 90_000 });
await page.waitForTimeout(1500);
await installOverlay(page);
await page.waitForTimeout(500);
probe = await page.evaluate(async () => {
for (const canvas of document.querySelectorAll("canvas")) {
const gl = canvas.getContext("webgl2");
if (!gl) continue;
const debug = gl.getExtension("WEBGL_debug_renderer_info");
return { vendor: debug ? gl.getParameter(debug.UNMASKED_VENDOR_WEBGL) : "REDACTED", renderer: debug ? gl.getParameter(debug.UNMASKED_RENDERER_WEBGL) : "REDACTED", version: gl.getParameter(gl.VERSION) };
}
return null;
});
if (!probe) throw new Error("no WebGL2 canvas for video probe");
if (gpuMode === "hardware" && /SwiftShader|llvmpipe|software/i.test(String(probe.renderer))) throw new Error(`hardware GPU unavailable: ${probe.renderer}`);
const objectCount = async () => Number(/Objects (\d+)/.exec(await page.getByTestId("scene-stats").innerText())?.[1] ?? "0");
const caption = async (text) => { await page.evaluate((value) => window.__lessonSetCaption?.(value), text); };
const shot = async (id) => {
await hideOverlay(page, true);
const file = path.join(LESSON, "video-steps", `${id}.jpg`);
await page.screenshot({ path: file, type: "jpeg", quality: 84 });
await hideOverlay(page, false);
return path.relative(REPO, file);
};
const waitRender = async (previousSrc, resolution) => {
await page.waitForFunction(({ previous, expected }) => {
const image = document.querySelector(".pbr-render-result img");
const button = [...document.querySelectorAll(".pbr-render-panel button")].find((candidate) => /渲染 PBR|渲染中/.test(candidate.textContent ?? ""));
return image instanceof HTMLImageElement && image.naturalWidth === expected && image.getAttribute("src") !== previous && button?.textContent?.includes("渲染 PBR");
}, { previous: previousSrc, expected: resolution }, { timeout: 180_000 });
};
const render = async (resolution = 1024, camera = "SCENE") => {
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption(String(resolution));
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption(camera);
const previous = await page.locator(".pbr-render-result img").getAttribute("src").catch(() => null);
await moveClick(page, page.getByRole("button", { name: "渲染 PBR", exact: true }));
await waitRender(previous, resolution);
return page.locator(".pbr-render-panel").getAttribute("data-texture-status");
};
const dismissDiscardDialog = async () => {
const dialog = page.getByRole("dialog", { name: "保存当前工程的修改?", exact: true });
await dialog.waitFor({ state: "visible", timeout: 1500 }).catch(() => undefined);
if (await dialog.count()) {
const discard = dialog.getByRole("button", { name: "不保存,继续", exact: true });
if (await discard.count()) await discard.click().catch(() => undefined);
}
};
const waitStudioReady = async () => {
await page.locator('main.blender-app[data-project-id]').waitFor({ timeout: 90_000 });
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 90_000 });
await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
};
const ensureRendering = async () => {
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true }).catch(() => undefined);
await page.locator(".pbr-lesson-panel").waitFor({ timeout: 15_000 }).catch(() => undefined);
};
const openStudio = async () => {
await ensureRendering();
try {
const button = page.getByRole("button", { name: "载入练习摄影棚", exact: true });
const enabled = await button.isEnabled({ timeout: 5000 }).catch(() => false);
if (!enabled) throw new Error("studio button not enabled");
await moveClick(page, button);
await dismissDiscardDialog();
await Promise.race([
waitStudioReady(),
new Promise((_, reject) => setTimeout(() => reject(new Error("studio UI path timed out")), 45_000)),
]);
return;
}
catch (error) {
const message = error instanceof Error ? error.message : String(error);
errors.push(`open-studio UI path: ${message}`);
console.error(`open-studio UI path failed: ${message}; falling back to file input`);
await page.screenshot({ path: path.join(DIAG, "open-studio-ui-failed.png") }).catch(() => undefined);
}
await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" });
await page.locator("main.blender-app").waitFor({ timeout: 90_000 });
await installOverlay(page);
await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO);
await dismissDiscardDialog();
await waitStudioReady();
await ensureRendering();
};
const importGlb = async () => {
const chooserPromise = page.waitForEvent("filechooser", { timeout: 30_000 });
await moveClick(page, page.getByRole("button", { name: "导入猴头 GLB", exact: true }));
const chooser = await chooserPromise;
await chooser.setFiles(GLB);
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
};
const chapter = async (id, title, action) => {
await caption(title);
const startMs = Date.now() - recordingStart;
try {
await action();
}
catch (error) {
const screenshot = await shot(id).catch(() => "");
await page.screenshot({ path: path.join(DIAG, `failure-${id}.png`) }).catch(() => undefined);
const state = await page.evaluate(() => ({
projectId: document.querySelector("main.blender-app")?.getAttribute("data-project-id"),
stats: document.querySelector('[data-testid="scene-stats"]')?.textContent,
engine: document.querySelector('[data-testid="engine-status"]')?.textContent,
workspace: document.querySelector(".workspace-tab.active")?.textContent,
lessonPanels: document.querySelectorAll(".pbr-lesson-panel").length,
renderImage: document.querySelectorAll(".pbr-render-result img").length,
textureStatus: document.querySelector(".pbr-render-panel")?.getAttribute("data-texture-status"),
})).catch(() => null);
timeline.push({ id, title, startMs, endMs: Date.now() - recordingStart, screenshot, error: String(error), state });
errors.push(`chapter ${id}: ${error instanceof Error ? error.stack ?? error.message : String(error)} :: ${JSON.stringify(state)}`);
throw error;
}
const waitMs = Math.max(0, startMs + durationOf(id) + 15_800 - (Date.now() - recordingStart));
if (waitMs > 0) await page.waitForTimeout(waitMs);
const screenshot = await shot(id);
timeline.push({ id, title, startMs, endMs: Date.now() - recordingStart, screenshot });
console.log(`chapter ${id} ${startMs}ms-${Date.now() - recordingStart}ms`);
};
await chapter("intro", "开场:会得到什么", async () => {
await page.waitForTimeout(1200);
});
await chapter("open-studio", "载入摄影棚", async () => {
await openStudio();
});
await chapter("import-glb", "导入猴头 GLB", async () => {
await importGlb();
});
if (!smoke) {
await chapter("asset-library", "认识 Shading 与 RE 资产库", async () => {
await page.getByRole("button", { name: "Shading", exact: true }).click({ force: true });
await page.locator(".pbr-asset-library").waitFor({ timeout: 60_000 });
await page.waitForFunction(() => document.querySelectorAll(".pbr-asset-card").length >= 20, undefined, { timeout: 90_000 });
await page.locator('select[aria-label="资产来源"]').selectOption("library:re-pbr-v1");
await page.waitForFunction(() => document.querySelectorAll('[data-library="library:re-pbr-v1"]').length === 6, undefined, { timeout: 60_000 });
const card = page.locator('[data-library="library:re-pbr-v1"]').first();
await card.scrollIntoViewIfNeeded();
await moveClick(page, card);
});
await chapter("assign-materials", "分配六种材质", async () => {
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
await page.locator(".pbr-lesson-picker").waitFor({ timeout: 60_000 });
for (const name of MATERIALS) {
const card = page.locator('[data-testid="pbr-lesson-material"]', { hasText: name }).first();
await card.scrollIntoViewIfNeeded();
await moveClick(page, card);
await card.dblclick();
await page.waitForFunction((expected) => {
const select = document.querySelector('select[aria-label^="材质槽"]');
return select instanceof HTMLSelectElement && select.value.startsWith(`material:${expected}`);
}, name, { timeout: 180_000 });
await page.waitForFunction(() => !document.querySelector(".pbr-lesson-busy"), undefined, { timeout: 180_000 });
}
});
await chapter("render-settings", "设置渲染", async () => {
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0");
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("SCENE_CAMERA");
await page.locator('select[aria-label="PBR 渲染分辨率"]').selectOption("1024");
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("SCENE");
});
await chapter("render-download", "渲染与下载 PNG", async () => {
const status = await render(1024, "SCENE");
if (!/^ready:/.test(String(status))) throw new Error(`textures not ready: ${status}`);
const download = page.waitForEvent("download", { timeout: 60_000 });
await moveClick(page, page.locator(".pbr-render-download"));
const file = await download;
await file.saveAs(path.join(OUT, "video-render-1024.png"));
});
await chapter("save-reopen", "保存与重开", async () => {
const saving = page.waitForEvent("download", { timeout: 60_000 });
await moveClick(page, page.getByRole("button", { name: "保存项目", exact: true }));
const saved = await saving;
const savedPath = path.join(OUT, "lesson-video-saved.blend");
await saved.saveAs(savedPath);
await page.locator('[data-testid="blend-file-input"]').setInputFiles(savedPath);
await page.waitForTimeout(500);
const dialog = page.getByRole("dialog", { name: "保存当前工程的修改?", exact: true });
if (await dialog.count()) {
const discard = dialog.getByRole("button", { name: "不保存,继续", exact: true });
if (await discard.count()) await discard.click();
}
await page.getByText(/Engine: SceneIR/).first().waitFor({ timeout: 90_000 });
await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), undefined, { timeout: 90_000 });
await page.getByRole("button", { name: "Rendering", exact: true }).click({ force: true });
await render(1024, "SCENE");
});
await chapter("diagnostics", "诊断视图与教学曝光", async () => {
for (const mode of ["BASE_COLOR", "NORMAL", "ROUGHNESS"]) {
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption(mode);
await render(512, "SCENE");
await page.waitForTimeout(500);
}
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("2");
await render(512, "SCENE");
});
await chapter("displacement", "位移与细分", async () => {
await page.locator('select[aria-label="PBR 诊断视图"]').selectOption("FINAL");
await page.locator('select[aria-label="PBR 教学曝光"]').selectOption("0");
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
await render(1024, "SCENE");
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("100");
await render(1024, "SCENE");
});
await chapter("camera", "对比视角", async () => {
await page.locator('select[aria-label="PBR 位移强度"]').selectOption("1");
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("COMPARISON");
await page.locator('select[aria-label="PBR 渲染相机"]').selectOption("VIEWPORT");
await render(1024, "VIEWPORT");
await page.locator('select[aria-label="PBR 视角预设"]').selectOption("SCENE_CAMERA");
});
await chapter("hq-2048", "高清 2048 贴图", async () => {
const hqButton = page.getByRole("button", { name: /加载高清贴图|高清贴图已加载|加载中/ });
await hqButton.scrollIntoViewIfNeeded();
if ((await hqButton.getAttribute("disabled")) === null) await moveClick(page, hqButton);
await page.getByRole("button", { name: "高清贴图已加载" }).waitFor({ timeout: 180_000 });
const hqSource = page.getByRole("button", { name: "高清 2048", exact: true });
await moveClick(page, hqSource);
const card = page.locator('[data-testid="pbr-lesson-material"]', { hasText: "black_worn_wall_brick_HQ" }).first();
await card.scrollIntoViewIfNeeded();
await moveClick(page, card);
await card.dblclick();
await page.waitForFunction(() => {
const select = document.querySelector('select[aria-label^="材质槽"]');
return select instanceof HTMLSelectElement && select.value.startsWith("material:black_worn_wall_brick_HQ");
}, undefined, { timeout: 180_000 });
await page.waitForFunction(() => !document.querySelector(".pbr-lesson-busy"), undefined, { timeout: 180_000 });
await render(1024, "SCENE");
});
await chapter("summary", "总结与教案", async () => {
await page.waitForTimeout(1200);
});
}
const durationMs = Date.now() - recordingStart;
if (!smoke) {
if (durationMs < 430_000) {
await page.waitForTimeout(430_000 - durationMs);
}
else if (durationMs > 700_000) {
throw new Error(`recording too long for the 7-12 minute contract: ${durationMs}ms`);
}
}
}
catch (error) {
fatalError = error;
errors.push(`fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}`);
if (page) await page.screenshot({ path: path.join(DIAG, "failure-final.png") }).catch(() => undefined);
}
finally {
if (context) await context.close().catch(() => undefined);
let screencast = null;
if (video) {
const webmPath = path.join(OUT, "screencast.webm");
await video.saveAs(webmPath).catch((error) => errors.push(`video save: ${error instanceof Error ? error.message : String(error)}`));
if (fs.existsSync(webmPath)) {
screencast = { path: path.relative(REPO, webmPath), sha256: fileSha(webmPath), bytes: fs.statSync(webmPath).size };
}
}
const finalDurationMs = Date.now() - recordingStart;
const report = {
schemaVersion: 1,
status: !fatalError && !errors.length && screencast && (smoke || timeline.length === 13) ? "PASS" : "FAIL",
gpu: probe,
gpuMode,
smoke,
durationMs: finalDurationMs,
chapters: timeline,
errors,
screencast,
};
fs.writeFileSync(path.join(OUT, "timeline.json"), JSON.stringify(report, null, 2) + "\n");
fs.writeFileSync(path.join(OUT, "record-report.json"), JSON.stringify(report, null, 2) + "\n");
console.log(JSON.stringify({ status: report.status, durationMs: finalDurationMs, chapters: timeline.length, errors: errors.length, video: screencast?.path ?? null }, null, 2));
if (browser) await browser.close().catch(() => undefined);
if (server) server.kill("SIGTERM");
if (report.status !== "PASS") process.exitCode = 1;
}
}
main().catch((error) => { console.error(error); process.exitCode = 1; });

View File

@@ -1,13 +1,13 @@
{
"schemaVersion": 1,
"generator": "tools/web/generate-pbr-library.py",
"scriptSha256": "afdd011544e3550dc2693b5107f9452f4abd6bbce465c6676833066ff4819867",
"scriptSha256": "990e7598ed5329ca19431ae6b3d27d219c11c74c11257a48ba816a2421e45791",
"blenderVersion": "5.2.0",
"blenderExecutable": "/home/mes123456/workinf_Blender_Wasm/build_blender_native/bin/blender",
"library": {
"path": "libraries/pbr-library.blend",
"sha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"byteLength": 311716
"sha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"byteLength": 241286
},
"catalogDefinition": {
"path": "blender_assets.cats.txt",
@@ -16,7 +16,7 @@
},
"previewManifest": {
"path": "preview-manifest.json",
"sha256": "8b085efa0f634ee616cee3d8aaee8dd68b4fa7f3afe667619ff968336bb7579e",
"sha256": "8fa5b4f2d69e26c6f1c7823fdc9fd1a8ec3f555cf3ee0c38691be782b0408510",
"byteLength": 4480
},
"counts": {

View File

@@ -297,12 +297,12 @@
"catalogId": "a5555555-5555-4555-8555-555555555555",
"catalogSimpleName": "",
"author": "Web Blender PBR Library",
"description": "Varnished wood with packed grain",
"description": "Warm matte wood colour",
"copyright": "CC0-1.0 generated by Web Blender",
"license": "CC0-1.0",
"tags": [
"wood",
"texture"
"warm"
],
"activeTag": 0,
"usePreferredImportMethod": true,

View File

@@ -82,10 +82,10 @@
"value": "PBR Emission Cyan"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "d020bea7c3dd4336b67fd77ae5b5c372e6a87db3cb45c98578fca7fd39a2ebfd",
"sha256": "319061129f3ce2283d023f53e4c067e6e69e1225c0a2f05d22ce202b23531b41",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -128,10 +128,10 @@
"value": "PBR Glass"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "001ee16c1a9f20ccdb2d010eded1a92a8009ede7f4c7cc752e0ebea43aed307f",
"sha256": "df6cc1eda01ec015d60d0ddfe5762caaf466b78c86616312752884601e269ea0",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -174,10 +174,10 @@
"value": "PBR Metal Brushed"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "0ecf4ac62ab403c65a3b6420758b202a056d8a3102ed2da38c9d6fab31cabcb1",
"sha256": "05cf00b11a8427a0f1a52c75e79c108bbd53082569a8cc6b001c37b69eed8805",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -220,10 +220,10 @@
"value": "PBR Metal Polished"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "271416b57e1eb8bd66e70ee43bb8d9f383220c0627daa921bd1a9c71676c01e3",
"sha256": "d006188537f82c9c04f7b60b5cc9fca3c0bb3a841bc30bee193fb096f76e08a1",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -266,10 +266,10 @@
"value": "PBR Plastic Red"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "84a8986141171fbadac0fc7a7160772fc005fe3f6a7ea4f05d6a300c8eaa6488",
"sha256": "8dcc2719d17c8d8b31ef3a9b758c15396f3c6de5268df8296fdcf995c0e0aabf",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -312,10 +312,10 @@
"value": "PBR Plastic White"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "9b0b26f2f580ee165285cb6290ee8734353dd97a954d80c71e05e80b560fdd55",
"sha256": "1c887f7b483acb4275c7f964ddb6c68488d0fb54220af422600d9cde4754b73a",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -358,10 +358,10 @@
"value": "PBR Rubber Black"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "5750849ebed9b80473589d6aec5bc8ecb68e00e4e1de92f0ec054cf6e0ea055c",
"sha256": "4599469a446133aefc7702c6fd68f96d3215ac74a21765463df4a8bf75406d16",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -377,12 +377,12 @@
"catalogId": "a5555555-5555-4555-8555-555555555555",
"catalogSimpleName": "",
"author": "Web Blender PBR Library",
"description": "Varnished wood with packed grain",
"description": "Warm matte wood colour",
"copyright": "CC0-1.0 generated by Web Blender",
"license": "CC0-1.0",
"tags": [
"wood",
"texture"
"warm"
],
"activeTag": 0,
"usePreferredImportMethod": true,
@@ -404,14 +404,14 @@
"value": "PBR Wood"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "c3dadde23c48d8519142ddde58948bf56493f60dec14f29e87214cd3c137dafa",
"sha256": "568be837953aa54da895f2de4edeb62ff3e9e818cfaa55d631d1fe9f00a0fa89",
"mimeType": "image/png",
"width": 256,
"height": 256,
"byteLength": 23857
"byteLength": 17327
}
},
{
@@ -464,10 +464,10 @@
"value": "PBR Cube Mesh"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "b268af61e06e4a8c2d1969cafb19f0c0f0194600dfa76e609cd89d733ad89f9c",
"sha256": "2ac1f1a4d5ba16d621371dd696e7a361b40fec2f2d26b8d9693ae1649021e2bd",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -524,10 +524,10 @@
"value": "PBR Cylinder Mesh"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "10c3de4227a055f63eebabdee899f045d01074f0cf8401952115370d3970add9",
"sha256": "83f64d3c12f0ee3da10782fd1da5a579f392fc1e2e9136cfafdfcd77fb1214f8",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -584,10 +584,10 @@
"value": "PBR Plane Mesh"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "b6d1a1eaae624f2cf62beabee8ab9dd0ec4faffddf73b3f6d12864b5779c267c",
"sha256": "be09597c5e3be5f2330756a9e9cf662d618db557e39b82072ee7fdbd6e4789b3",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -644,10 +644,10 @@
"value": "PBR Suzanne Mesh"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "67b77cd71f1f56146078b60981cca3966471272919d519f8a30bd452e566996d",
"sha256": "563b07833851af02c42c75bb1e084c2a2d39446d08c5da4fc4c8ac89964825fa",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -704,10 +704,10 @@
"value": "PBR Torus Mesh"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "754db892c78b8423660350e347d52e2e963e12f26ccf53c04a21eda137e96fa8",
"sha256": "b4bac45f3ac595f58808c9d0e22d269412184512df4613064fe324df9dd4c30e",
"mimeType": "image/png",
"width": 256,
"height": 256,
@@ -764,14 +764,14 @@
"value": "PBR UV Sphere Mesh"
}
],
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"sourcePath": "libraries/pbr-library.blend",
"preview": {
"sha256": "7b781c8b6915d7ba07bf4c6ba8962d7225d4d581554e84067c410232e5f0a17e",
"sha256": "c75a41a76c465a8b87b0f6e610766a9838c72b52a7056bef4fd86d8b3f528bfa",
"mimeType": "image/png",
"width": 256,
"height": 256,
"byteLength": 23758
"byteLength": 17425
}
}
],
@@ -780,7 +780,7 @@
"libraryId": "library:pbr-essentials-v1",
"name": "PBR Essentials",
"sourcePath": "libraries/pbr-library.blend",
"sourceSha256": "30d8e440b8e43b6ff484e51d91612f523ab6f86af491fc23d24f57b9971f9fc0",
"sourceSha256": "455d739566711e89208a691ffaac9e75f3586d1247626a0e6f86e96f96538795",
"dependencyIds": [],
"readOnly": true
}

View File

@@ -2,8 +2,8 @@
"schemaVersion": 1,
"previews": [
{
"file": "previews/0ecf4ac62ab403c65a3b6420758b202a056d8a3102ed2da38c9d6fab31cabcb1.png",
"sha256": "0ecf4ac62ab403c65a3b6420758b202a056d8a3102ed2da38c9d6fab31cabcb1",
"file": "previews/05cf00b11a8427a0f1a52c75e79c108bbd53082569a8cc6b001c37b69eed8805.png",
"sha256": "05cf00b11a8427a0f1a52c75e79c108bbd53082569a8cc6b001c37b69eed8805",
"byteLength": 16951,
"width": 256,
"height": 256,
@@ -11,8 +11,8 @@
"name": "PBR Metal Brushed"
},
{
"file": "previews/271416b57e1eb8bd66e70ee43bb8d9f383220c0627daa921bd1a9c71676c01e3.png",
"sha256": "271416b57e1eb8bd66e70ee43bb8d9f383220c0627daa921bd1a9c71676c01e3",
"file": "previews/d006188537f82c9c04f7b60b5cc9fca3c0bb3a841bc30bee193fb096f76e08a1.png",
"sha256": "d006188537f82c9c04f7b60b5cc9fca3c0bb3a841bc30bee193fb096f76e08a1",
"byteLength": 12309,
"width": 256,
"height": 256,
@@ -20,8 +20,8 @@
"name": "PBR Metal Polished"
},
{
"file": "previews/84a8986141171fbadac0fc7a7160772fc005fe3f6a7ea4f05d6a300c8eaa6488.png",
"sha256": "84a8986141171fbadac0fc7a7160772fc005fe3f6a7ea4f05d6a300c8eaa6488",
"file": "previews/8dcc2719d17c8d8b31ef3a9b758c15396f3c6de5268df8296fdcf995c0e0aabf.png",
"sha256": "8dcc2719d17c8d8b31ef3a9b758c15396f3c6de5268df8296fdcf995c0e0aabf",
"byteLength": 18947,
"width": 256,
"height": 256,
@@ -29,8 +29,8 @@
"name": "PBR Plastic Red"
},
{
"file": "previews/9b0b26f2f580ee165285cb6290ee8734353dd97a954d80c71e05e80b560fdd55.png",
"sha256": "9b0b26f2f580ee165285cb6290ee8734353dd97a954d80c71e05e80b560fdd55",
"file": "previews/1c887f7b483acb4275c7f964ddb6c68488d0fb54220af422600d9cde4754b73a.png",
"sha256": "1c887f7b483acb4275c7f964ddb6c68488d0fb54220af422600d9cde4754b73a",
"byteLength": 14367,
"width": 256,
"height": 256,
@@ -38,17 +38,17 @@
"name": "PBR Plastic White"
},
{
"file": "previews/c3dadde23c48d8519142ddde58948bf56493f60dec14f29e87214cd3c137dafa.png",
"sha256": "c3dadde23c48d8519142ddde58948bf56493f60dec14f29e87214cd3c137dafa",
"byteLength": 23857,
"file": "previews/568be837953aa54da895f2de4edeb62ff3e9e818cfaa55d631d1fe9f00a0fa89.png",
"sha256": "568be837953aa54da895f2de4edeb62ff3e9e818cfaa55d631d1fe9f00a0fa89",
"byteLength": 17327,
"width": 256,
"height": 256,
"idType": "MATERIAL",
"name": "PBR Wood"
},
{
"file": "previews/001ee16c1a9f20ccdb2d010eded1a92a8009ede7f4c7cc752e0ebea43aed307f.png",
"sha256": "001ee16c1a9f20ccdb2d010eded1a92a8009ede7f4c7cc752e0ebea43aed307f",
"file": "previews/df6cc1eda01ec015d60d0ddfe5762caaf466b78c86616312752884601e269ea0.png",
"sha256": "df6cc1eda01ec015d60d0ddfe5762caaf466b78c86616312752884601e269ea0",
"byteLength": 11159,
"width": 256,
"height": 256,
@@ -56,8 +56,8 @@
"name": "PBR Glass"
},
{
"file": "previews/d020bea7c3dd4336b67fd77ae5b5c372e6a87db3cb45c98578fca7fd39a2ebfd.png",
"sha256": "d020bea7c3dd4336b67fd77ae5b5c372e6a87db3cb45c98578fca7fd39a2ebfd",
"file": "previews/319061129f3ce2283d023f53e4c067e6e69e1225c0a2f05d22ce202b23531b41.png",
"sha256": "319061129f3ce2283d023f53e4c067e6e69e1225c0a2f05d22ce202b23531b41",
"byteLength": 8512,
"width": 256,
"height": 256,
@@ -65,8 +65,8 @@
"name": "PBR Emission Cyan"
},
{
"file": "previews/5750849ebed9b80473589d6aec5bc8ecb68e00e4e1de92f0ec054cf6e0ea055c.png",
"sha256": "5750849ebed9b80473589d6aec5bc8ecb68e00e4e1de92f0ec054cf6e0ea055c",
"file": "previews/4599469a446133aefc7702c6fd68f96d3215ac74a21765463df4a8bf75406d16.png",
"sha256": "4599469a446133aefc7702c6fd68f96d3215ac74a21765463df4a8bf75406d16",
"byteLength": 14314,
"width": 256,
"height": 256,
@@ -74,8 +74,8 @@
"name": "PBR Rubber Black"
},
{
"file": "previews/67b77cd71f1f56146078b60981cca3966471272919d519f8a30bd452e566996d.png",
"sha256": "67b77cd71f1f56146078b60981cca3966471272919d519f8a30bd452e566996d",
"file": "previews/563b07833851af02c42c75bb1e084c2a2d39446d08c5da4fc4c8ac89964825fa.png",
"sha256": "563b07833851af02c42c75bb1e084c2a2d39446d08c5da4fc4c8ac89964825fa",
"byteLength": 17002,
"width": 256,
"height": 256,
@@ -83,8 +83,8 @@
"name": "PBR Suzanne"
},
{
"file": "previews/b268af61e06e4a8c2d1969cafb19f0c0f0194600dfa76e609cd89d733ad89f9c.png",
"sha256": "b268af61e06e4a8c2d1969cafb19f0c0f0194600dfa76e609cd89d733ad89f9c",
"file": "previews/2ac1f1a4d5ba16d621371dd696e7a361b40fec2f2d26b8d9693ae1649021e2bd.png",
"sha256": "2ac1f1a4d5ba16d621371dd696e7a361b40fec2f2d26b8d9693ae1649021e2bd",
"byteLength": 29597,
"width": 256,
"height": 256,
@@ -92,17 +92,17 @@
"name": "PBR Cube"
},
{
"file": "previews/7b781c8b6915d7ba07bf4c6ba8962d7225d4d581554e84067c410232e5f0a17e.png",
"sha256": "7b781c8b6915d7ba07bf4c6ba8962d7225d4d581554e84067c410232e5f0a17e",
"byteLength": 23758,
"file": "previews/c75a41a76c465a8b87b0f6e610766a9838c72b52a7056bef4fd86d8b3f528bfa.png",
"sha256": "c75a41a76c465a8b87b0f6e610766a9838c72b52a7056bef4fd86d8b3f528bfa",
"byteLength": 17425,
"width": 256,
"height": 256,
"idType": "OBJECT",
"name": "PBR UV Sphere"
},
{
"file": "previews/754db892c78b8423660350e347d52e2e963e12f26ccf53c04a21eda137e96fa8.png",
"sha256": "754db892c78b8423660350e347d52e2e963e12f26ccf53c04a21eda137e96fa8",
"file": "previews/b4bac45f3ac595f58808c9d0e22d269412184512df4613064fe324df9dd4c30e.png",
"sha256": "b4bac45f3ac595f58808c9d0e22d269412184512df4613064fe324df9dd4c30e",
"byteLength": 12764,
"width": 256,
"height": 256,
@@ -110,8 +110,8 @@
"name": "PBR Torus"
},
{
"file": "previews/10c3de4227a055f63eebabdee899f045d01074f0cf8401952115370d3970add9.png",
"sha256": "10c3de4227a055f63eebabdee899f045d01074f0cf8401952115370d3970add9",
"file": "previews/83f64d3c12f0ee3da10782fd1da5a579f392fc1e2e9136cfafdfcd77fb1214f8.png",
"sha256": "83f64d3c12f0ee3da10782fd1da5a579f392fc1e2e9136cfafdfcd77fb1214f8",
"byteLength": 15681,
"width": 256,
"height": 256,
@@ -119,8 +119,8 @@
"name": "PBR Cylinder"
},
{
"file": "previews/b6d1a1eaae624f2cf62beabee8ab9dd0ec4faffddf73b3f6d12864b5779c267c.png",
"sha256": "b6d1a1eaae624f2cf62beabee8ab9dd0ec4faffddf73b3f6d12864b5779c267c",
"file": "previews/be09597c5e3be5f2330756a9e9cf662d618db557e39b82072ee7fdbd6e4789b3.png",
"sha256": "be09597c5e3be5f2330756a9e9cf662d618db557e39b82072ee7fdbd6e4789b3",
"byteLength": 9663,
"width": 256,
"height": 256,

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View File

@@ -0,0 +1,6 @@
# This is an Asset Catalog Definition file for Blender.
# Derived from the desktop RE 5000PBR catalog by generate-re-pbr-library.py
VERSION 1
a8000000-0000-4000-8000-000000000001:Materials/RE5000PBR:RE5000PBR

View File

@@ -0,0 +1,356 @@
{
"schemaVersion": 1,
"generator": "tools/web/generate-re-pbr-library.py",
"scriptSha256": "18c91935ef26ba2925d6a52c55bdc361df637013c292b9cda04288b80ba57b5d",
"source": {
"path": "/home/mes123456/workinf_Blender_Wasm/re/5000PBR\u7cbe\u7b80\u7248/5000+ PBR catalog.blend",
"sha256": "54942f057f913c9923752ba0d4ddcfa6d1f77e98c5e812b83409fb128968c125",
"byteLength": 13578408
},
"library": {
"path": "/home/mes123456/workinf_Blender_Wasm/web/app/public/assets/re-pbr-hq/re-pbr-library.blend",
"sha256": "2875cf7b3715fb0658582e2c6219300b72681e477079506e1b41a8551cd62efe",
"byteLength": 10846909,
"libraryId": "library:re-pbr-v1",
"name": "re-pbr-library.blend"
},
"catalog": {
"id": "a8000000-0000-4000-8000-000000000001",
"path": "Materials/RE5000PBR",
"name": "RE5000PBR"
},
"materials": [
"Black_Anti_Slip_grip_HQ",
"Blackish_Brick_Wall_016_HQ",
"black_randome_tile_HQ",
"black_shinning_tile_HQ",
"black_tiles_wall_HQ",
"black_worn_wall_brick_HQ"
],
"textures": [
{
"name": "Black_Anti_Slip_grip_Height.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 73960
},
{
"name": "Black_Anti_Slip_grip_Base_Color.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "sRGB",
"packedBytes": 146278
},
{
"name": "Black_Anti_Slip_grip_Roughness.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 151468
},
{
"name": "Black_Anti_Slip_grip_Normal.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 1154027
},
{
"name": "Blackish_Brick_Wall_016_Height.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 172621
},
{
"name": "Blackish_Brick_Wall_016_Base_Color.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "sRGB",
"packedBytes": 67926
},
{
"name": "Blackish_Brick_Wall_016_Roughness.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 50446
},
{
"name": "Blackish_Brick_Wall_016_Normal.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 633381
},
{
"name": "black_randome_tile_Height.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 222062
},
{
"name": "black_randome_tile_Base_Color.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "sRGB",
"packedBytes": 329012
},
{
"name": "black_randome_tile_Roughness.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 347395
},
{
"name": "black_randome_tile_Normal.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 527548
},
{
"name": "black_shinning_tile_Height.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 239331
},
{
"name": "black_shinning_tile_Base_Color.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "sRGB",
"packedBytes": 381704
},
{
"name": "black_shinning_tile_Roughness.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 504655
},
{
"name": "black_shinning_tile_Normal.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 541824
},
{
"name": "black_tiles_wall_Height.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 410400
},
{
"name": "black_tiles_wall_Base_Color.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "sRGB",
"packedBytes": 449747
},
{
"name": "black_tiles_wall_Roughness.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 703107
},
{
"name": "black_tiles_wall_Normal.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 995398
},
{
"name": "black_worn_wall_brick_Height.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 133666
},
{
"name": "black_worn_wall_brick_Base_Color.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "sRGB",
"packedBytes": 491057
},
{
"name": "black_worn_wall_brick_Roughness.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 223997
},
{
"name": "black_worn_wall_brick_Normal.jpg",
"from": [
2048,
2048
],
"to": [
2048,
2048
],
"colorspace": "Non-Color",
"packedBytes": 1771589
}
],
"textureOptions": {
"base_color_size": 2048,
"normal_size": 2048,
"scalar_size": 2048,
"preview_size": 256,
"preserve_original": true,
"material_suffix": "_HQ",
"library_id": "library:re-pbr-hq-v1"
},
"previewCount": 6,
"previewBytes": 149700,
"blenderVersion": "5.2.0"
}

View File

@@ -0,0 +1,130 @@
{
"schemaVersion": 1,
"task": "M12-01B",
"enablingTask": true,
"parityStateChange": false,
"blenderVersion": "5.2.0",
"catalogDefinition": {
"fileName": "blender_assets.cats.txt",
"version": 1,
"records": [
{
"catalogId": "a8000000-0000-4000-8000-000000000001",
"path": "Materials/RE5000PBR",
"simpleName": "RE5000PBR",
"parentPath": "Materials"
}
]
},
"assets": [
{
"idType": "MATERIAL",
"name": "Black_Anti_Slip_grip_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "APPEND",
"customProperties": []
},
{
"idType": "MATERIAL",
"name": "Blackish_Brick_Wall_016_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "LINK",
"customProperties": []
},
{
"idType": "MATERIAL",
"name": "black_randome_tile_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "LINK",
"customProperties": []
},
{
"idType": "MATERIAL",
"name": "black_shinning_tile_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "LINK",
"customProperties": []
},
{
"idType": "MATERIAL",
"name": "black_tiles_wall_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "LINK",
"customProperties": []
},
{
"idType": "MATERIAL",
"name": "black_worn_wall_brick_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "LINK",
"customProperties": []
}
],
"nextTask": "M12-01C"
}

View File

@@ -0,0 +1,204 @@
{
"schemaVersion": 2,
"revision": 1,
"catalogs": [
{
"catalogId": "a8000000-0000-4000-8000-000000000001",
"path": "Materials/RE5000PBR",
"simpleName": "RE5000PBR",
"parentPath": "Materials"
}
],
"assets": [
{
"assetId": "asset:6373ae4f76ea92cb477a9414c66fe7f5eac8bf7f8aaac086e5a5ce9ecf141b2b",
"assetLibraryIdentifier": "library:re-pbr-hq-v1",
"relativeAssetIdentifier": "Material/Black_Anti_Slip_grip_HQ",
"idType": "MATERIAL",
"name": "Black_Anti_Slip_grip_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "APPEND",
"customProperties": [],
"sourceSha256": "2875cf7b3715fb0658582e2c6219300b72681e477079506e1b41a8551cd62efe",
"sourcePath": "libraries/re-pbr-hq-library.blend",
"preview": {
"sha256": "e9038e9683cd76770fdabe22c376b37f191041833aa3c2bbfa0d0e995e209ddd",
"mimeType": "image/png",
"width": 256,
"height": 256,
"byteLength": 21087
}
},
{
"assetId": "asset:08b96adfded16a13248377d3ad0e12f464d92ce533335b10587f83b5279413c1",
"assetLibraryIdentifier": "library:re-pbr-hq-v1",
"relativeAssetIdentifier": "Material/Blackish_Brick_Wall_016_HQ",
"idType": "MATERIAL",
"name": "Blackish_Brick_Wall_016_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "LINK",
"customProperties": [],
"sourceSha256": "2875cf7b3715fb0658582e2c6219300b72681e477079506e1b41a8551cd62efe",
"sourcePath": "libraries/re-pbr-hq-library.blend",
"preview": {
"sha256": "803b7e215262a9473070d40a53ce81cc01de2ae40ccaa488a5514fee331209d8",
"mimeType": "image/png",
"width": 256,
"height": 256,
"byteLength": 18055
}
},
{
"assetId": "asset:a2c1041a1d6215268bdbebe341eb6931764a2e1abf4fc4993f2bd3a7dd4afbe6",
"assetLibraryIdentifier": "library:re-pbr-hq-v1",
"relativeAssetIdentifier": "Material/black_randome_tile_HQ",
"idType": "MATERIAL",
"name": "black_randome_tile_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "LINK",
"customProperties": [],
"sourceSha256": "2875cf7b3715fb0658582e2c6219300b72681e477079506e1b41a8551cd62efe",
"sourcePath": "libraries/re-pbr-hq-library.blend",
"preview": {
"sha256": "eae0fc7ec1bd457ebbda586e6231f799c7aca781c36745db083a7793cf691c1b",
"mimeType": "image/png",
"width": 256,
"height": 256,
"byteLength": 26253
}
},
{
"assetId": "asset:b3e134218790d3729e443f8a15c5633e4a2aac2f5c5526ca83cca77b4258fef4",
"assetLibraryIdentifier": "library:re-pbr-hq-v1",
"relativeAssetIdentifier": "Material/black_shinning_tile_HQ",
"idType": "MATERIAL",
"name": "black_shinning_tile_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "LINK",
"customProperties": [],
"sourceSha256": "2875cf7b3715fb0658582e2c6219300b72681e477079506e1b41a8551cd62efe",
"sourcePath": "libraries/re-pbr-hq-library.blend",
"preview": {
"sha256": "3776584a42adde8cdc696bd97a52d60511f7fe0cda4941dedf4ab69d94c99c11",
"mimeType": "image/png",
"width": 256,
"height": 256,
"byteLength": 27002
}
},
{
"assetId": "asset:6d120259b5599576ca81dc82a04b80e89642dec863b4ec86106b148dea520718",
"assetLibraryIdentifier": "library:re-pbr-hq-v1",
"relativeAssetIdentifier": "Material/black_tiles_wall_HQ",
"idType": "MATERIAL",
"name": "black_tiles_wall_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "LINK",
"customProperties": [],
"sourceSha256": "2875cf7b3715fb0658582e2c6219300b72681e477079506e1b41a8551cd62efe",
"sourcePath": "libraries/re-pbr-hq-library.blend",
"preview": {
"sha256": "d02ba42c3c029c96b9e94ed226853ebea7db51286eba30b277ecca65ecf9ce68",
"mimeType": "image/png",
"width": 256,
"height": 256,
"byteLength": 29038
}
},
{
"assetId": "asset:485c61689a4c4a721fe71418802bc5e7d473c258004c045d81a93e9bd8f27a34",
"assetLibraryIdentifier": "library:re-pbr-hq-v1",
"relativeAssetIdentifier": "Material/black_worn_wall_brick_HQ",
"idType": "MATERIAL",
"name": "black_worn_wall_brick_HQ",
"catalogId": "a8000000-0000-4000-8000-000000000001",
"catalogSimpleName": "",
"author": "",
"description": "RE 5000PBR material for the Web Blender render lesson",
"copyright": "",
"license": "",
"tags": [
"PBR",
"RE5000PBR"
],
"activeTag": 0,
"usePreferredImportMethod": false,
"preferredImportMethod": "LINK",
"customProperties": [],
"sourceSha256": "2875cf7b3715fb0658582e2c6219300b72681e477079506e1b41a8551cd62efe",
"sourcePath": "libraries/re-pbr-hq-library.blend",
"preview": {
"sha256": "6c8a255ec7afc3969c0882e4057458241e5b141df0ea6c029c7e8fc585d0a8f5",
"mimeType": "image/png",
"width": 256,
"height": 256,
"byteLength": 28265
}
}
],
"libraries": [
{
"libraryId": "library:re-pbr-hq-v1",
"name": "RE 5000PBR HQ",
"sourcePath": "libraries/re-pbr-hq-library.blend",
"sourceSha256": "2875cf7b3715fb0658582e2c6219300b72681e477079506e1b41a8551cd62efe",
"dependencyIds": [],
"readOnly": true
}
]
}

View File

@@ -0,0 +1,59 @@
{
"schemaVersion": 1,
"previews": [
{
"file": "previews/e9038e9683cd76770fdabe22c376b37f191041833aa3c2bbfa0d0e995e209ddd.png",
"sha256": "e9038e9683cd76770fdabe22c376b37f191041833aa3c2bbfa0d0e995e209ddd",
"byteLength": 21087,
"width": 256,
"height": 256,
"idType": "MATERIAL",
"name": "Black_Anti_Slip_grip_HQ"
},
{
"file": "previews/803b7e215262a9473070d40a53ce81cc01de2ae40ccaa488a5514fee331209d8.png",
"sha256": "803b7e215262a9473070d40a53ce81cc01de2ae40ccaa488a5514fee331209d8",
"byteLength": 18055,
"width": 256,
"height": 256,
"idType": "MATERIAL",
"name": "Blackish_Brick_Wall_016_HQ"
},
{
"file": "previews/eae0fc7ec1bd457ebbda586e6231f799c7aca781c36745db083a7793cf691c1b.png",
"sha256": "eae0fc7ec1bd457ebbda586e6231f799c7aca781c36745db083a7793cf691c1b",
"byteLength": 26253,
"width": 256,
"height": 256,
"idType": "MATERIAL",
"name": "black_randome_tile_HQ"
},
{
"file": "previews/3776584a42adde8cdc696bd97a52d60511f7fe0cda4941dedf4ab69d94c99c11.png",
"sha256": "3776584a42adde8cdc696bd97a52d60511f7fe0cda4941dedf4ab69d94c99c11",
"byteLength": 27002,
"width": 256,
"height": 256,
"idType": "MATERIAL",
"name": "black_shinning_tile_HQ"
},
{
"file": "previews/d02ba42c3c029c96b9e94ed226853ebea7db51286eba30b277ecca65ecf9ce68.png",
"sha256": "d02ba42c3c029c96b9e94ed226853ebea7db51286eba30b277ecca65ecf9ce68",
"byteLength": 29038,
"width": 256,
"height": 256,
"idType": "MATERIAL",
"name": "black_tiles_wall_HQ"
},
{
"file": "previews/6c8a255ec7afc3969c0882e4057458241e5b141df0ea6c029c7e8fc585d0a8f5.png",
"sha256": "6c8a255ec7afc3969c0882e4057458241e5b141df0ea6c029c7e8fc585d0a8f5",
"byteLength": 28265,
"width": 256,
"height": 256,
"idType": "MATERIAL",
"name": "black_worn_wall_brick_HQ"
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Some files were not shown because too many files have changed in this diff Show More