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>
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
|
||||
270
web/app/src/tutorial/pbr/curriculum.ts
Normal file
270
web/app/src/tutorial/pbr/curriculum.ts
Normal 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";
|
||||
80
web/app/src/tutorial/pbr/validators.ts
Normal file
80
web/app/src/tutorial/pbr/validators.ts
Normal 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));
|
||||
}
|
||||
Reference in New Issue
Block a user