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

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

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

View File

@@ -38,7 +38,7 @@ export function TutorialLanding({ course }: { course: TutorialCourse }) {
return <main className="parts-tutorial-landing" data-course={course.id}>
<header className="parts-landing-top">
<a className="parts-landing-brand" href="/" aria-label="返回 Web Blender"><span className="brand-mark" aria-hidden="true"><span className="brand-orbit" /></span><span>Blender Web</span></a>
<nav aria-label="教程导航"><a href="/manuals/WEB_Blender_Function_User_Guide.pdf" target="_blank" rel="noopener">使用手册</a><a href="/" className="parts-landing-return">直接打开 Blender</a></nav>
<nav aria-label="教程导航"><a href="/manuals/WEB_Blender_Function_User_Guide.pdf" target="_blank" rel="noopener">使用手册</a>{course.lessonUrl ? <a href={course.lessonUrl} target="_blank" rel="noopener">渲染图文/视频教案</a> : null}<a href="/" className="parts-landing-return">直接打开 Blender</a></nav>
</header>
<section className="parts-landing-hero">
<div>
@@ -64,7 +64,7 @@ export function TutorialLanding({ course }: { course: TutorialCourse }) {
<section className="parts-section" aria-labelledby="parts-gallery-title">
<div className="parts-section-head"><h2 id="parts-gallery-title">课堂实录</h2><p>{course.galleryLead}</p></div>
<div className="parts-gallery-grid">{course.gallery.map((item) => <figure className="parts-gallery-item" key={item.step}>
<img src={`${course.assetDir}/steps/${item.step}.jpg`} alt={`${item.title} 的实际编辑器截图`} loading="lazy" width="1440" height="900" />
<img src={`${course.assetDir}/${item.image ?? `steps/${item.step}.jpg`}`} alt={`${item.title} 的实际编辑器截图`} loading="lazy" width="1440" height="900" />
<figcaption><strong>{item.title}</strong><span>{item.note}</span></figcaption>
</figure>)}</div>
</section>

View File

@@ -179,7 +179,7 @@ export function TutorialOverlay({ course, api, context }: { course: TutorialCour
<ul className="tutorial-checks" aria-live="polite">{checks.map((item) => <CheckRow key={item.id} item={item} />)}</ul>
</> : null}
{isSummary ? <div className="tutorial-complete">
<h3>零件清单</h3>
<h3>{course.summaryPartsTitle ?? "零件清单"}</h3>
<ul className="tutorial-part-list">{course.summaryParts.map((part) => <li key={part}>{part}</li>)}</ul>
<h3>你练习过的能力</h3>
<ul className="tutorial-skill-list">{course.skills.map((skill) => <li key={skill}>{skill}</li>)}</ul>
@@ -212,6 +212,12 @@ export async function runAction(action: TutorialAction, api: TutorialApi): Promi
if (action.kind === "command") return api.applyCommand(action.command);
if (action.kind === "join") return api.joinObjects(action.names, action.activeName);
if (action.kind === "parent") return api.setParents(action.names, action.activeName);
if (action.kind === "workspace") return api.switchWorkspace(action.workspaceId);
if (action.kind === "select") return api.selectObjects(action.names, action.activeName);
if (action.kind === "asset") return action.mode === "object" ? api.addLibraryAsset(action.name) : api.assignLibraryMaterial(action.name);
if (action.kind === "openScene") return api.openScene(action.url, action.fileName);
if (action.kind === "render") return api.renderPbr(action.resolution, action.camera);
if (action.kind === "renderDownload") return api.downloadPbrRender();
if (action.kind === "save") { await api.save(action.fileName); return true; }
if (action.kind === "export") { await api.exportGLB(); return true; }
await delay(action.ms);

View File

@@ -1,8 +1,9 @@
import { PARTS_TUTORIAL_REQUIRED_STEPS, PARTS_TUTORIAL_STEPS, PARTS_TUTORIAL_STORAGE_KEY } from "./parts/curriculum";
import { REDUCER_TUTORIAL_REQUIRED_STEPS, REDUCER_TUTORIAL_STEPS, REDUCER_TUTORIAL_STORAGE_KEY } from "./reducer/curriculum";
import { PBR_TUTORIAL_REQUIRED_STEPS, PBR_TUTORIAL_STEPS, PBR_TUTORIAL_STORAGE_KEY } from "./pbr/curriculum";
import type { TutorialStep } from "./parts/types";
export type CourseId = "parts" | "reducer";
export type CourseId = "parts" | "reducer" | "pbr";
export interface TutorialCourseCard {
name: string;
@@ -14,6 +15,8 @@ export interface TutorialGalleryItem {
step: string;
title: string;
note: string;
/** Path relative to assetDir; default is steps/<step>.jpg. */
image?: string;
}
/** Everything a course needs that was previously hardcoded in the parts UI. */
@@ -26,6 +29,7 @@ export interface TutorialCourse {
storageKey: string;
opfsFile: string;
assetDir: string;
lessonUrl?: string;
startUrl: string;
saveStepId: string;
summaryStepId: string;
@@ -47,6 +51,7 @@ export interface TutorialCourse {
bottomCtaContinue: string;
parts: readonly TutorialCourseCard[];
summaryParts: readonly string[];
summaryPartsTitle?: string;
gallery: readonly TutorialGalleryItem[];
skills: readonly string[];
}
@@ -162,6 +167,72 @@ export const COURSES = {
"装配与交付:集合、Empty 父子层级、实时装配树、材质、链接复制、保存与 GLB 导出",
],
},
pbr: {
id: "pbr",
title: "PBR 渲染入门",
heading: "从零开始 PBR 渲染",
steps: PBR_TUTORIAL_STEPS,
requiredSteps: PBR_TUTORIAL_REQUIRED_STEPS,
storageKey: PBR_TUTORIAL_STORAGE_KEY,
opfsFile: "pbr-progress.json",
assetDir: "/tutorial/pbr",
lessonUrl: "/tutorial/pbr/render-lesson.html",
startUrl: "/?ui=web&tutorial=pbr&start=1",
saveStepId: "download-save",
summaryStepId: "summary",
checkpointEvery: 1,
defaultProjectName: "PBR渲染入门.blend",
pythonFileName: "PbrTutorial.py",
overlayTitle: "PBR 渲染互动教程",
reopenAria: "展开 PBR 渲染教程",
heroTitle: "从零开始",
heroAccent: "PBR 渲染",
eyebrow: "互动教学 · 初学者 · 约 30–45 分钟",
lead: "在真实 Web Blender 里完成 3 个模型 × 4 种材质的 12 张渲染图:追加 Suzanne、Cube、Torus,分配金属、玻璃、木纹与自发光材质,最后用场景相机渲染并下载 PNG。",
partsLead: "从资产库、材质球到 Web PBR 渲染,覆盖初学者最常用的渲染流程。",
galleryLead: "以下画面由有头 Chromium 实际操作并渲染生成,不是示意图。",
bottomCta: "进入渲染教程",
bottomCtaContinue: "继续上次进度",
parts: [
{ name: "模型", note: "Suzanne / Cube / Torus", icon: "shaft" },
{ name: "材质", note: "金属 / 玻璃 / 木色 / 自发光", icon: "ring" },
{ name: "灯光", note: "三点面光 + 世界环境", icon: "washer" },
{ name: "渲染", note: "1024 场景相机 → PNG", icon: "bolt" },
],
summaryParts: ["PBR Suzanne · 拉丝金属", "PBR Cube · 玻璃", "PBR Torus · 暖色木质", "PBR Studio Ground · 青色自发光"],
summaryPartsTitle: "本课完成的组合",
skills: [
"Shading 工作区与 PBR 资产库:搜索、预览、双击追加模型与分配材质",
"PBR 材质参数:Metallic、Roughness、Transmission、IOR、Emission 与颜色",
"摄影棚场景:相机、三点面光、世界环境与地面",
"渲染交付:分辨率、场景相机、PNG 下载、OPFS 保存与 .blend 保存",
],
gallery: [
{ step: "workspace-shading", title: "资产库 · Shading 与 PBR 卡片", note: "打开 Shading 工作区,浏览 6 个模型与 14 个材质。" },
{ step: "load-studio", title: "摄影棚 · 相机与三点光", note: "载入带相机、面光与世界环境的固定场景。" },
{ step: "add-suzanne", title: "模型 · 追加 PBR Suzanne", note: "双击资产卡片,模型自动出现在原点并选中。" },
{ step: "add-cube", title: "模型 · 追加 PBR Cube", note: "用同一入口添加第二个模型。" },
{ step: "add-torus", title: "模型 · 追加 PBR Torus", note: "三个模型准备完成。" },
{ step: "assign-metal", title: "材质 · 拉丝金属", note: "Metallic 1.0、Roughness 0.30。" },
{ step: "assign-glass", title: "材质 · 透射玻璃", note: "Transmission 1.0、IOR 1.45。" },
{ step: "assign-wood", title: "材质 · 暖色木质", note: "暖色 Base Color、Roughness 0.52。" },
{ step: "assign-emission", title: "材质 · 青色自发光", note: "Emission Color 青色、Strength 5.0。" },
{ step: "render-setup", title: "渲染 · 1024 场景相机", note: "Rendering 工作区选择分辨率与场景相机。" },
{ step: "download-save", title: "交付 · PNG 与 .blend", note: "下载渲染图并保存工程。" },
{ step: "render-suzanne-metal", image: "renders/suzanne-metal-brushed.jpg", title: "Suzanne · 拉丝金属", note: "金属高光与拉丝粗糙度。" },
{ step: "render-suzanne-glass", image: "renders/suzanne-glass.jpg", title: "Suzanne · 玻璃", note: "透射与折射对比。" },
{ step: "render-suzanne-wood", image: "renders/suzanne-wood.jpg", title: "Suzanne · 暖色木质", note: "暖色哑光材质在曲面上的表现。" },
{ step: "render-suzanne-emission", image: "renders/suzanne-emission-cyan.jpg", title: "Suzanne · 自发光", note: "青色自发光强度 5.0。" },
{ step: "render-cube-metal", image: "renders/cube-metal-brushed.jpg", title: "Cube · 拉丝金属", note: "平面上的金属反射。" },
{ step: "render-cube-glass", image: "renders/cube-glass.jpg", title: "Cube · 玻璃", note: "厚立方体的透射效果。" },
{ step: "render-cube-wood", image: "renders/cube-wood.jpg", title: "Cube · 暖色木质", note: "平面上的暖色哑光表面。" },
{ step: "render-cube-emission", image: "renders/cube-emission-cyan.jpg", title: "Cube · 自发光", note: "自发光立方体轮廓。" },
{ step: "render-torus-metal", image: "renders/torus-metal-brushed.jpg", title: "Torus · 拉丝金属", note: "环面高光。" },
{ step: "render-torus-glass", image: "renders/torus-glass.jpg", title: "Torus · 玻璃", note: "曲面透射与焦散近似。" },
{ step: "render-torus-wood", image: "renders/torus-wood.jpg", title: "Torus · 暖色木质", note: "环面上的暖色哑光材质。" },
{ step: "render-torus-emission", image: "renders/torus-emission-cyan.jpg", title: "Torus · 自发光", note: "环面自发光。" },
],
},
} satisfies Record<CourseId, TutorialCourse>;
export type AvailableCourseId = keyof typeof COURSES;

View File

@@ -1,6 +1,20 @@
import type { SceneSnapshotIR } from "../../../../protocol/scene-ir";
import type { WebEngineEditCommand } from "../../../../protocol/web-engine";
export type PbrCameraMode = "SCENE" | "VIEWPORT";
export type PbrResolution = 256 | 512 | 1024 | 2048;
export interface TutorialRenderResult {
width: number;
height: number;
resolution: PbrResolution;
cameraMode: PbrCameraMode;
saved: string;
downloaded: boolean;
renderedAt: number;
url?: string;
}
export interface TutorialContext {
snapshot: SceneSnapshotIR | null;
/** The default project has finished opening and the native Main owns a scene. */
@@ -14,6 +28,10 @@ export interface TutorialContext {
exported: boolean;
projectId: string | null;
projectDisplayName: string | null;
workspace: string;
assetLibraryReady: boolean;
assetCount: number;
renderResult: TutorialRenderResult | null;
}
export interface StepCheck {
@@ -37,7 +55,13 @@ export type TutorialAction =
| { kind: "python"; source: string }
| { kind: "save"; fileName?: string }
| { kind: "export" }
| { kind: "wait"; ms: number };
| { kind: "wait"; ms: number }
| { kind: "workspace"; workspaceId: string }
| { kind: "select"; names: string[]; activeName: string }
| { kind: "asset"; name: string; mode: "object" | "material" }
| { kind: "openScene"; url: string; fileName: string }
| { kind: "render"; resolution: PbrResolution; camera: PbrCameraMode }
| { kind: "renderDownload" };
export interface TutorialStep {
id: string;
@@ -68,6 +92,13 @@ export interface TutorialApi {
recoverProject: (projectId: string, displayName: string) => Promise<boolean>;
/** Persists the current scene under a course-owned OPFS project id without a download. */
ensureCourseProject: (projectId: string, displayName: string) => Promise<boolean>;
switchWorkspace: (workspaceId: string) => Promise<boolean>;
selectObjects: (names: string[], activeName: string) => Promise<boolean>;
addLibraryAsset: (name: string) => Promise<boolean>;
assignLibraryMaterial: (name: string) => Promise<boolean>;
openScene: (url: string, fileName: string) => Promise<boolean>;
renderPbr: (resolution: PbrResolution, cameraMode: PbrCameraMode) => Promise<boolean>;
downloadPbrRender: () => Promise<boolean>;
}
export interface PartsTutorialProgress {

View File

@@ -0,0 +1,270 @@
import type { TutorialStep } from "../parts/types";
import { check, findNode, meshOf, nodeCheck } from "../parts/validators";
import {
materialEmissionCheck,
materialNumberCheck,
objectMaterialCheck,
sceneCameraIs,
} from "./validators";
const hasFaces = (snapshot: Parameters<typeof findNode>[0], name: string): boolean => {
const mesh = meshOf(snapshot, findNode(snapshot, name));
return Boolean(mesh && (mesh.faceCount ?? 0) > 0);
};
const suiteSteps: TutorialStep[] = [
{
id: "intro",
chapter: "开始",
title: "欢迎:用浏览器完成 12 张材质渲染",
summary: "本课程在真实 Web Blender 中完成 3 个模型 × 4 种材质,共 12 张渲染图。左侧编辑器、右侧教程,完成检查后点“下一步”。",
instructions: [
{ text: "本课程使用 PBR 资产库里的 Suzanne、Cube、Torus 和 4 种材质。" },
{ text: "每一步右侧都会实时检查场景;卡住时点“演示本步”。" },
{ text: "课程结束后,你会在 Rendering 工作区得到可下载的 PBR 渲染图。" },
],
guide: true,
validate: () => [],
},
{
id: "workspace-shading",
chapter: "准备",
title: "打开 Shading 与 PBR 资产库",
summary: "Shading 工作区同时显示 3D 视口、Shader Editor、材质球与 PBR 资产库。",
instructions: [
{ text: "点击顶部工作区标签“Shading”。" },
{ text: "确认出现“PBR 资产库”,共有 6 个模型和 14 个材质(20 个资产)。" },
{ text: "单击卡片只选中;双击才会添加模型或分配材质。" },
],
hints: ["如果资产库显示载入失败,点击底部的“修复资产库”。"],
demo: [
{ kind: "workspace", workspaceId: "Shading" },
{ kind: "wait", ms: 800 },
],
validate: (context) => [
check("workspace-shading", "当前工作区是 Shading", context.workspace === "Shading", "点击顶部 Shading 标签"),
check("asset-library-ready", "PBR 资产库已载入 20 个资产", context.assetLibraryReady && context.assetCount >= 20, "等待资产库载入;失败时点“修复资产库”"),
],
},
{
id: "load-studio",
chapter: "准备",
title: "载入摄影棚场景",
summary: "固定摄影棚带相机、三点面光和深灰世界,保证 12 张成片光线一致。",
instructions: [
{ text: "点击“演示本步”载入课程摄影棚;它走真实的“打开 .blend”入口。" },
{ text: "场景中应包含 PBR Studio Camera、三盏面光和地面,且没有默认 Cube。" },
],
demo: [
{ kind: "openScene", url: "/tutorial/pbr/studio.blend", fileName: "PBR_Studio.blend" },
{ kind: "wait", ms: 1500 },
],
validate: (context) => [
nodeCheck(context.snapshot, "PBR Studio Camera", "CAMERA"),
check("studio-lights", "场景至少有 3 盏灯", (context.snapshot?.lights.length ?? 0) >= 3, "载入课程摄影棚"),
check("studio-camera-active", "场景相机已设为 PBR Studio Camera", sceneCameraIs(context.snapshot, "PBR Studio Camera"), "在场景属性中设置相机"),
check("studio-clean", "默认 Cube 已移除", !findNode(context.snapshot, "Cube"), "载入课程摄影棚会移除默认 Cube"),
],
},
{
id: "add-suzanne",
chapter: "模型",
title: "添加 PBR Suzanne",
summary: "Suzanne 是经典 Blender 猴头模型,适合观察金属高光。",
instructions: [
{ text: "在资产库搜索“PBR Suzanne”。" },
{ text: "双击卡片,或选中后点“添加到场景”。" },
{ text: "模型出现在原点并自动选中。" },
],
demo: [
{ kind: "workspace", workspaceId: "Shading" },
{ kind: "asset", name: "PBR Suzanne", mode: "object" },
{ kind: "wait", ms: 1000 },
],
validate: (context) => [
nodeCheck(context.snapshot, "PBR Suzanne", "MESH"),
check("suzanne-mesh", "PBR Suzanne 包含网格面", hasFaces(context.snapshot, "PBR Suzanne")),
],
},
{
id: "add-cube",
chapter: "模型",
title: "添加 PBR Cube",
summary: "立方体有平整表面,适合观察玻璃与折射。",
instructions: [
{ text: "在资产库搜索“PBR Cube”。" },
{ text: "双击添加到场景,保持 Shading 工作区。" },
],
demo: [
{ kind: "workspace", workspaceId: "Shading" },
{ kind: "asset", name: "PBR Cube", mode: "object" },
{ kind: "wait", ms: 1000 },
],
validate: (context) => [
nodeCheck(context.snapshot, "PBR Cube", "MESH"),
check("cube-mesh", "PBR Cube 包含网格面", hasFaces(context.snapshot, "PBR Cube")),
],
},
{
id: "add-torus",
chapter: "模型",
title: "添加 PBR Torus",
summary: "圆环的曲面能清楚展示木纹贴图与 UV 映射。",
instructions: [
{ text: "在资产库搜索“PBR Torus”。" },
{ text: "双击添加,三个模型现在都在摄影棚中。" },
],
demo: [
{ kind: "workspace", workspaceId: "Shading" },
{ kind: "asset", name: "PBR Torus", mode: "object" },
{ kind: "wait", ms: 1000 },
],
validate: (context) => [
nodeCheck(context.snapshot, "PBR Torus", "MESH"),
check("torus-mesh", "PBR Torus 包含网格面", hasFaces(context.snapshot, "PBR Torus")),
],
},
{
id: "assign-metal",
chapter: "材质",
title: "材质一:拉丝金属",
summary: "Metallic = 1,Roughness ≈ 0.30,观察材质球上的高光带。",
instructions: [
{ text: "选中 PBR Suzanne(Outliner 或视口单击)。" },
{ text: "在资产库双击“PBR Metal Brushed”,属性面板材质槽 1 会显示该材质。" },
{ text: "在 Shader Editor 观察 Metallic、Roughness 与材质球。" },
],
demo: [
{ kind: "select", names: ["PBR Suzanne"], activeName: "PBR Suzanne" },
{ kind: "asset", name: "PBR Metal Brushed", mode: "material" },
{ kind: "workspace", workspaceId: "Shading" },
{ kind: "wait", ms: 1200 },
],
validate: (context) => [
objectMaterialCheck(context.snapshot, "PBR Suzanne", "PBR Metal Brushed"),
materialNumberCheck(context.snapshot, "PBR Metal Brushed", "metallic", 1.0, 0.01, "metal-metallic", "金属度为 1.0"),
materialNumberCheck(context.snapshot, "PBR Metal Brushed", "roughness", 0.30, 0.02, "metal-roughness", "粗糙度约为 0.30"),
],
},
{
id: "assign-glass",
chapter: "材质",
title: "材质二:透射玻璃",
summary: "Transmission = 1、IOR = 1.45、Roughness ≈ 0.03。",
instructions: [
{ text: "选中 PBR Cube。" },
{ text: "在资产库双击“PBR Glass”。" },
{ text: "观察属性面板的 Transmission、IOR 和 Roughness。" },
],
demo: [
{ kind: "select", names: ["PBR Cube"], activeName: "PBR Cube" },
{ kind: "asset", name: "PBR Glass", mode: "material" },
{ kind: "workspace", workspaceId: "Shading" },
{ kind: "wait", ms: 1200 },
],
validate: (context) => [
objectMaterialCheck(context.snapshot, "PBR Cube", "PBR Glass"),
materialNumberCheck(context.snapshot, "PBR Glass", "transmissionWeight", 1.0, 0.01, "glass-transmission", "透射权重为 1.0"),
materialNumberCheck(context.snapshot, "PBR Glass", "ior", 1.45, 0.01, "glass-ior", "IOR 为 1.45"),
materialNumberCheck(context.snapshot, "PBR Glass", "roughness", 0.03, 0.02, "glass-roughness", "粗糙度约为 0.03"),
],
},
{
id: "assign-wood",
chapter: "材质",
title: "材质三:暖色木质",
summary: "PBR Wood 使用暖色 Base Color 与 Roughness ≈ 0.52,呈现哑光木质外观。",
instructions: [
{ text: "选中 PBR Torus。" },
{ text: "在资产库双击“PBR Wood”。" },
{ text: "在 Shader Editor 观察 Base Color 与 Roughness 参数。" },
],
demo: [
{ kind: "select", names: ["PBR Torus"], activeName: "PBR Torus" },
{ kind: "asset", name: "PBR Wood", mode: "material" },
{ kind: "workspace", workspaceId: "Shading" },
{ kind: "wait", ms: 1200 },
],
validate: (context) => [
objectMaterialCheck(context.snapshot, "PBR Torus", "PBR Wood"),
materialNumberCheck(context.snapshot, "PBR Wood", "roughness", 0.52, 0.03, "wood-roughness", "粗糙度约为 0.52"),
],
},
{
id: "assign-emission",
chapter: "材质",
title: "材质四:青色自发光",
summary: "Emission Color 为青色、Emission Strength = 5.0,分配给摄影棚地面。",
instructions: [
{ text: "选中摄影棚地面 PBR Studio Ground。" },
{ text: "在资产库双击“PBR Emission Cyan”。" },
{ text: "观察 Emission Color 与 Emission Strength。" },
],
demo: [
{ kind: "select", names: ["PBR Studio Ground"], activeName: "PBR Studio Ground" },
{ kind: "asset", name: "PBR Emission Cyan", mode: "material" },
{ kind: "workspace", workspaceId: "Shading" },
{ kind: "wait", ms: 1200 },
],
validate: (context) => [
objectMaterialCheck(context.snapshot, "PBR Studio Ground", "PBR Emission Cyan"),
...materialEmissionCheck(context.snapshot, "PBR Emission Cyan", [0, 0.85, 1], 5.0),
],
},
{
id: "render-setup",
chapter: "渲染",
title: "打开 Rendering,选择分辨率与相机",
summary: "Rendering 工作区的 Web PBR 渲染会读取 WASM SceneIR,用 three.js WebGL2 输出 PNG。",
instructions: [
{ text: "点击顶部“Rendering”工作区。" },
{ text: "分辨率选 1024 × 1024,相机选“场景相机”。" },
{ text: "点击“渲染 PBR”,等待结果图出现。" },
],
demo: [
{ kind: "workspace", workspaceId: "Rendering" },
{ kind: "wait", ms: 800 },
{ kind: "render", resolution: 1024, camera: "SCENE" },
],
validate: (context) => [
check("render-size", "渲染结果为 1024 × 1024", context.renderResult?.width === 1024 && context.renderResult?.height === 1024, "在 Rendering 工作区渲染"),
check("render-camera", "使用场景相机渲染", context.renderResult?.cameraMode === "SCENE", "相机选择“场景相机”"),
check("render-saved", "渲染已写入 OPFS", Boolean(context.renderResult?.saved), "等待渲染完成"),
],
},
{
id: "download-save",
chapter: "渲染",
title: "下载 PNG 并保存工程",
summary: "把渲染图下载到本地,并把工程保存为 PBR渲染入门.blend。",
instructions: [
{ text: "点击结果下方的“下载 PNG”。" },
{ text: "使用“文件 → 保存”,文件名使用“PBR渲染入门.blend”。" },
],
demo: [
{ kind: "renderDownload" },
{ kind: "save", fileName: "PBR渲染入门.blend" },
{ kind: "wait", ms: 1500 },
],
validate: (context) => [
check("render-downloaded", "渲染 PNG 已下载", Boolean(context.renderResult?.downloaded), "点击“下载 PNG”"),
check("save", "工程已保存", context.saved, "使用文件 → 保存"),
],
},
{
id: "summary",
chapter: "完成",
title: "完成总结:12 张材质对比",
summary: "你完成了 3 个模型 × 4 种材质:Suzanne、Cube、Torus 分别与 Metal Brushed、Glass、Wood、Emission Cyan 组合。",
instructions: [
{ text: "12 张成片位于 web/app/public/tutorial/pbr/renders/,教程首页可直接查看。" },
{ text: "继续练习:替换相机、调整分辨率、尝试 Plastic Red 与 Rubber Black。" },
],
guide: true,
validate: () => [],
},
];
export const PBR_TUTORIAL_STEPS = suiteSteps;
export const PBR_TUTORIAL_REQUIRED_STEPS = suiteSteps.filter((step) => !step.guide);
export const PBR_TUTORIAL_STORAGE_KEY = "web-blender.pbr-tutorial.v1";

View File

@@ -0,0 +1,80 @@
import type { MaterialIR, MaterialNodeIR, MaterialTextureBindingIR, SceneSnapshotIR } from "../../../../protocol/scene-ir";
import type { StepCheck } from "../parts/types";
import { approx, check, findNode, materialByName, materialSlotIds, nameMatches } from "../parts/validators";
export type MaterialNumberField =
| "roughness"
| "metallic"
| "transmissionWeight"
| "coatWeight"
| "coatRoughness"
| "emissionStrength"
| "ior";
export function materialOfSlot(snapshot: SceneSnapshotIR | null, objectName: string, slotIndex = 0): MaterialIR | undefined {
const node = findNode(snapshot, objectName);
const materialId = materialSlotIds(snapshot, node)[slotIndex];
return materialId ? snapshot?.materials.find((material) => material.id === materialId) : undefined;
}
export function objectMaterialCheck(
snapshot: SceneSnapshotIR | null,
objectName: string,
expectedMaterial: string,
slotIndex = 0,
): StepCheck {
const material = materialOfSlot(snapshot, objectName, slotIndex);
return check(
`material:${objectName}:${slotIndex + 1}`,
`「${objectName}」材质槽 ${slotIndex + 1} 是 ${expectedMaterial}`,
Boolean(material) && nameMatches(material!.name, expectedMaterial),
material ? `当前是 ${material.name}` : "请先选中对象并在资产库双击材质",
);
}
export function materialNumberCheck(
snapshot: SceneSnapshotIR | null,
materialName: string,
field: MaterialNumberField,
expected: number,
tolerance: number,
id: string,
label: string,
): StepCheck {
const material = materialByName(snapshot, materialName);
const value = material?.[field];
return check(id, label, approx(value, expected, tolerance), `当前 ${field} = ${value ?? "缺失"}`);
}
export function materialEmissionCheck(
snapshot: SceneSnapshotIR | null,
materialName: string,
expectedColor: [number, number, number],
expectedStrength: number,
): StepCheck[] {
const material = materialByName(snapshot, materialName);
return [
check(
"emission-color",
`「${materialName}」自发光颜色约为青色 (${expectedColor.join(", ")})`,
Boolean(material) && expectedColor.every((value, index) => approx(material!.emissionColor[index], value, 0.08)),
material ? `当前颜色 ${material.emissionColor.slice(0, 3).map((value) => value.toFixed(2)).join(", ")}` : "请先分配自发光材质",
),
materialNumberCheck(snapshot, materialName, "emissionStrength", expectedStrength, 0.2, "emission-strength", `「${materialName}」自发光强度为 ${expectedStrength}`),
];
}
export function materialHasTextureBinding(material: MaterialIR | undefined, usage: MaterialTextureBindingIR["usage"]): boolean {
return Boolean(material?.textureBindings?.some((binding) => binding.usage === usage));
}
export function materialHasNode(material: MaterialIR | undefined, type: MaterialNodeIR["type"]): boolean {
return Boolean(material?.nodes?.some((node) => node.type === type));
}
export function sceneCameraIs(snapshot: SceneSnapshotIR | null, cameraName: string): boolean {
const cameraId = snapshot?.scenes[0]?.cameraObjectId;
if (!cameraId) return false;
const node = snapshot?.nodes.find((candidate) => candidate.id === cameraId && candidate.type === "CAMERA");
return Boolean(node && nameMatches(node.name, cameraName));
}