feat(web): finish RE 5000PBR lesson video and fix non-ASCII GLB import
- 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>
9
.gitignore
vendored
@@ -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
|
||||
*~
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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);
|
||||
|
||||
72
docs/status/PBR_TUTORIAL.md
Normal 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 像素级一致性不在本教程范围。
|
||||
104
docs/status/RE_PBR_LIMITS_20261112.md
Normal 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;用户实际使用验收仍待执行。
|
||||
127
docs/status/RE_PBR_RENDER_20261111.md
Normal 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 当作整体产品验收。
|
||||
140
docs/status/RE_PBR_RENDER_20261112.md
Normal 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`
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
BIN
re/5000PBR精简版/5000+ PBR catalog.blend
Normal file
9
re/5000PBR精简版/blender_assets.cats.txt
Normal 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
tests/files/web/pbr-render-lesson/monkey-head.glb
Normal file
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 146 KiB |
|
After Width: | Height: | Size: 113 KiB |
52
tests/golden/RE-PBR-LIMITS-001/diagnostics/report.json
Normal 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."
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 386 KiB |
42
tests/golden/RE-PBR-LIMITS-001/displacement/report.json
Normal 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."
|
||||
]
|
||||
}
|
||||
84
tests/golden/RE-PBR-LIMITS-001/gpu/batch0-gpu-enable.json
Normal 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."
|
||||
}
|
||||
}
|
||||
|
After Width: | Height: | Size: 403 KiB |
BIN
tests/golden/RE-PBR-LIMITS-001/hq/hq-2048-1024.png
Normal file
|
After Width: | Height: | Size: 387 KiB |
38
tests/golden/RE-PBR-LIMITS-001/hq/report.json
Normal 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."
|
||||
]
|
||||
}
|
||||
102
tests/golden/RE-PBR-LIMITS-001/manifest.json
Normal 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."
|
||||
]
|
||||
}
|
||||
BIN
tests/golden/RE-PBR-RENDER-001/lesson-page-dist.png
Normal file
|
After Width: | Height: | Size: 315 KiB |
BIN
tests/golden/RE-PBR-RENDER-001/lesson-page.png
Normal file
|
After Width: | Height: | Size: 315 KiB |
79
tests/golden/RE-PBR-RENDER-001/manifest.json
Normal 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"
|
||||
}
|
||||
}
|
||||
BIN
tests/golden/RE-PBR-RENDER-001/re-pbr-lesson-saved.blend
Normal file
|
After Width: | Height: | Size: 326 KiB |
|
After Width: | Height: | Size: 397 KiB |
|
After Width: | Height: | Size: 540 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 426 KiB |
|
After Width: | Height: | Size: 123 KiB |
|
After Width: | Height: | Size: 369 KiB |
|
After Width: | Height: | Size: 458 KiB |
|
After Width: | Height: | Size: 131 KiB |
|
After Width: | Height: | Size: 398 KiB |
|
After Width: | Height: | Size: 521 KiB |
|
After Width: | Height: | Size: 139 KiB |
|
After Width: | Height: | Size: 364 KiB |
|
After Width: | Height: | Size: 469 KiB |
|
After Width: | Height: | Size: 129 KiB |
|
After Width: | Height: | Size: 386 KiB |
|
After Width: | Height: | Size: 518 KiB |
|
After Width: | Height: | Size: 135 KiB |
|
After Width: | Height: | Size: 386 KiB |
411
tests/golden/RE-PBR-RENDER-001/report.json
Normal 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": []
|
||||
}
|
||||
109
tests/golden/RE-PBR-VIDEO-001/narration.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
111
tests/golden/RE-PBR-VIDEO-001/record-report.json
Normal 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
|
||||
}
|
||||
}
|
||||
111
tests/golden/RE-PBR-VIDEO-001/timeline.json
Normal 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
|
||||
}
|
||||
}
|
||||
22
tests/golden/RE-PBR-VIDEO-001/video-probe.json
Normal 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"
|
||||
}
|
||||
}
|
||||
|
||||
117
tests/golden/RE-PBR-VIDEO-001/video-report.json
Normal 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。"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
356
tools/web/capture-re-pbr-lesson.mjs
Normal 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;
|
||||
});
|
||||
@@ -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");
|
||||
|
||||
@@ -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));
|
||||
|
||||
99
tools/web/check-pbr-tutorial-captures.mjs
Normal 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));
|
||||
106
tools/web/check-re-pbr-lesson.mjs
Normal 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));
|
||||
130
tools/web/check-re-pbr-library.mjs
Normal 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));
|
||||
99
tools/web/check-re-pbr-limits.mjs
Normal 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));
|
||||
95
tools/web/check-re-pbr-video.mjs
Normal 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));
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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()
|
||||
|
||||
130
tools/web/generate-pbr-tutorial-renders.py
Normal 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)
|
||||
161
tools/web/generate-pbr-tutorial-studio.py
Normal 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])
|
||||
329
tools/web/generate-re-pbr-library.py
Normal 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()
|
||||
184
tools/web/generate-re-pbr-narration.py
Normal 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()
|
||||
71
tools/web/re-pbr-video-chapters.json
Normal 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。"
|
||||
}
|
||||
]
|
||||
}
|
||||
470
tools/web/record-re-pbr-lesson-video.mjs
Normal 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; });
|
||||
@@ -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": {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 29 KiB After Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 8.3 KiB After Width: | Height: | Size: 8.3 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 17 KiB After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 15 KiB After Width: | Height: | Size: 15 KiB |
|
Before Width: | Height: | Size: 18 KiB After Width: | Height: | Size: 18 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 9.4 KiB |
|
Before Width: | Height: | Size: 23 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 11 KiB |
6
web/app/public/assets/re-pbr-hq/blender_assets.cats.txt
Normal 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
|
||||
356
web/app/public/assets/re-pbr-hq/build-report.json
Normal 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"
|
||||
}
|
||||
130
web/app/public/assets/re-pbr-hq/desktop-canonical.json
Normal 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"
|
||||
}
|
||||
204
web/app/public/assets/re-pbr-hq/manifest.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
59
web/app/public/assets/re-pbr-hq/preview-manifest.json
Normal 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"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 26 KiB |