diff --git a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md index 8665c5201..c26617864 100644 --- a/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md +++ b/docs/status/WEB_BLENDER_FULL_COURSE_20261113.md @@ -1,7 +1,7 @@ # Web Blender 全站功能补齐与入门课程 — 实施进度 日期:2026-11-13 -状态:**进行中(UV、Texture Paint、Geometry Nodes、Spreadsheet、Compositor 完成;课程/视频与其余缺口待实现)** +状态:**进行中(五块编辑器完成;15 模块图文课程 + 2 条配音视频完成;逐模块短视频与发布待补齐)** ## 本轮铁律 @@ -116,10 +116,34 @@ - OPFS 保存 → reload → 最近项目恢复 → 3 节点/2 连线/位置与 exposure=1.5 保留。 - 回归:Geometry Nodes、Spreadsheet、UV、Texture Paint 共 8 个用例复跑 **8/8 PASS(2.1m)**。 -## 待实现(按计划顺序) +### 8. 15 模块入门课程与配音视频 -1. 其余缺口:Area 编辑器选择/⋮、Camera Perspective、Preferences、Outliner、Graph Editor。 -2. 15 模块课程、15 个短视频、OPFS 保存重开流程、增量发布到 `https://49.232.250.35:58014/`。 +- 新 `tools/web/course/full-course.json`(15 模块、目标、步骤、提示、警告、视频引用)与生成器 + `tools/web/course/generate-full-course.mjs`,输出 + `web/app/public/tutorial/full/{index.html,course.css,course.json}`;无 inline script/style、无远程 URL。 +- 15 模块:M01 界面与保存、M02 导航/选择/变换、M03 网格编辑、M04 修改器、M05 材质与 PBR 资产库、 + M06 UV、M07 Texture Paint、M08 灯光相机渲染、M09 结果/诊断/GLB、M10 动画、M11 Compositor、 + M12 Geometry Nodes、M13 Spreadsheet、M14 保存重开、M15 脚本与能力边界。 +- 截图:`tools/web/course/capture-full-course.mjs` 在 1600×900 有头 Chromium 中逐模块实走并截图, + **15/15 PASS**;产物 `web/app/public/tutorial/full/steps/M01..M15-*.jpg`(61 张,约 4.5 MB), + 报告 `tests/golden/COURSE-FULL-001/capture-report.json`。 +- 视频 1:`/tutorial/full/video/full-course-editors-720p.mp4` + (195.72 s,1280×720,H.264/AAC,7.57 MB,9 章,中文配音 + VTT + poster):UV、纹理绘制、Compositor、 + Geometry Nodes、Spreadsheet、OPFS 保存重开。录制脚本 + `tools/web/course/record-full-course-video.mjs`,配音稿 `tools/web/course/editors-video-chapters.json`。 +- 视频 2:复用 `/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4`(667.04 s,13 章)覆盖 M08/M09。 +- 检查:`node tools/web/course/check-full-course.mjs` → **PASS**:15 模块、2 视频、所有图片/视频/VTT 存在, + 无 inline script/style/远程 URL,视频为 H.264/AAC 1280×720。 +- 课程入口:`TutorialLanding` 顶部“全站入门课程”链接到 `/tutorial/full/index.html`。 +- 浏览器 smoke:页面 15 个模块、15 个目录链接、两个 video readyState=4(195.72 s / 667.04 s)。 + +## 待实现 + +1. 逐模块短视频:计划中的 15 条 3–8 分钟视频目前只完成 2 条(编辑器专课 + 渲染专课); + M01–M05、M10、M14、M15 仍以图文截图为主。 +2. 其余缺口:Area 编辑器选择/⋮(web 布局隐藏了区域头)、Camera Perspective 真实迷你视口、 + Preferences 扩展、Outliner 拖拽高亮/键盘多选、Graph Editor。 +3. 构建并增量发布到 `https://49.232.250.35:58014/`(保留回滚 release)。 ## 已知限制 / 未验证 @@ -133,7 +157,10 @@ - Compositor:IMAGE / RENDER_LAYER 节点可在本地求值器中执行,但不写入 Main(无资源持久化入口); Transform 的 scaleY 以 scaleX 统一写入;Alpha Over 的 Factor 不持久化;保存重开后节点 id 由原生 identifier 重新生成(节点名、类型、位置、连线与属性保留)。MIX 仅持久化 RGBA/Mix 模式。 -- 课程/视频尚未开始。 +- 课程为 1 个静态 HTML 总课程;配音视频只有 2 条(编辑器专课、渲染专课),不是计划的 15 条逐模块 + 短视频。M01–M05、M10、M14、M15 以图文和截图为主。 +- 动画工作区 Camera Perspective 仍为预览占位(已显式标注“预览”);web 布局隐藏区域头,Area 编辑器 + 切换/⋮ 菜单在 `?ui=web` 下不可见;Graph Editor 与完整 Outliner/Preferences parity 未实现。 - 本轮只验证 Chromium + RTX 4060;未验证其他浏览器。 - 既有保存下载型 spec 尚未迁移;在迁移完成前不要把这些旧 spec 的失败当作产品回归。 - 远端首次 wasm 加载 154–193 秒;课程录制只做本地,页面需提示首载时间。 diff --git a/tests/golden/COURSE-FULL-001/capture-report.json b/tests/golden/COURSE-FULL-001/capture-report.json new file mode 100644 index 000000000..05e7295bf --- /dev/null +++ b/tests/golden/COURSE-FULL-001/capture-report.json @@ -0,0 +1,18 @@ +{ + "schemaVersion": 1, + "generatedAt": "2026-11-12T21:44:43.270Z", + "gpuMode": "hardware", + "modules": [ + { + "moduleId": "M07", + "title": "Texture Paint 手绘贴图", + "status": "PASS", + "images": [ + "steps/M07-texture-paint.jpg", + "steps/M07-painted-stroke.jpg" + ], + "durationMs": 9901 + } + ], + "errors": [] +} diff --git a/tests/golden/COURSE-FULL-001/video/narration.json b/tests/golden/COURSE-FULL-001/video/narration.json new file mode 100644 index 000000000..2285b4a88 --- /dev/null +++ b/tests/golden/COURSE-FULL-001/video/narration.json @@ -0,0 +1,77 @@ +{ + "voice": "zh-CN-XiaoxiaoNeural", + "chapters": [ + { + "id": "intro", + "title": "开场:这几块编辑器现在能做什么", + "narration": "这一课把四块新增的编辑器连起来用一遍:UV 展开、纹理绘制、Compositor 合成、Geometry Nodes 与 Spreadsheet 数据检查。所有保存都用浏览器 OPFS,需要本地文件时再手动下载。", + "mp3": "../tests/golden/COURSE-FULL-001/video/narration/intro.mp3", + "wav": "../tests/golden/COURSE-FULL-001/video/narration/intro.wav", + "durationMs": 17400 + }, + { + "id": "open-studio", + "title": "打开练习工程", + "narration": "先打开课程摄影棚,再追加猴头 GLB。状态栏从六个对象变成七个,活动对象是 Suzanne。", + "mp3": "../tests/golden/COURSE-FULL-001/video/narration/open-studio.mp3", + "wav": "../tests/golden/COURSE-FULL-001/video/narration/open-studio.wav", + "durationMs": 9576 + }, + { + "id": "uv", + "title": "UV 展开", + "narration": "切到 UV Editing 工作区。桌面 Blender 的 UV 编辑器要求网格处于编辑模式,所以我们显式进入编辑模式。点击全选 UV,等待选择提交,再点击展开所选 UV。橙色点变成展开后的 UV 岛。", + "mp3": "../tests/golden/COURSE-FULL-001/video/narration/uv.mp3", + "wav": "../tests/golden/COURSE-FULL-001/video/narration/uv.wav", + "durationMs": 19344 + }, + { + "id": "texture-paint", + "title": "纹理绘制", + "narration": "回到 Shading 工作区,从 RE 资产库双击砖墙材质,再导入本地贴图作为材质图片。切到 Texture Paint,选择这张图,在画布上画一条白色笔画。松开鼠标后笔画提交,一次笔画只增加一个版本。", + "mp3": "../tests/golden/COURSE-FULL-001/video/narration/texture-paint.mp3", + "wav": "../tests/golden/COURSE-FULL-001/video/narration/texture-paint.wav", + "durationMs": 19320 + }, + { + "id": "compositor", + "title": "Compositor 合成", + "narration": "切到 Compositing。画布默认有一个 Composite 输出。添加 RGB 颜色和曝光节点,把 RGB 连到曝光,再把曝光连到 Composite。把曝光改成一点五,点击执行合成,右侧预览变亮。点击提交图,节点图写入原生 Main。", + "mp3": "../tests/golden/COURSE-FULL-001/video/narration/compositor.mp3", + "wav": "../tests/golden/COURSE-FULL-001/video/narration/compositor.wav", + "durationMs": 21480 + }, + { + "id": "geometry-nodes", + "title": "Geometry Nodes", + "narration": "选中猴头,切到 Geometry Nodes。添加 Transform 节点,从 Group Input 连到 Transform,再从 Transform 连到 Group Output。点击提交图,网格随修改器更新。", + "mp3": "../tests/golden/COURSE-FULL-001/video/narration/geometry-nodes.mp3", + "wav": "../tests/golden/COURSE-FULL-001/video/narration/geometry-nodes.wav", + "durationMs": 14424 + }, + { + "id": "spreadsheet", + "title": "Spreadsheet 数据检查", + "narration": "在同一个工作区下方的 Spreadsheet 里,把数据源切到顶点,可以看到位置和法线列。输入过滤值,只留下匹配的行;列面板可以隐藏或重排列。Spreadsheet 是只读的,不会修改场景。", + "mp3": "../tests/golden/COURSE-FULL-001/video/narration/spreadsheet.mp3", + "wav": "../tests/golden/COURSE-FULL-001/video/narration/spreadsheet.wav", + "durationMs": 17232 + }, + { + "id": "save-reopen", + "title": "保存到 OPFS 并重开", + "narration": "点击顶栏保存项目,工程写入浏览器 OPFS。刷新页面,从最近项目重新打开。UV、纹理笔画、合成节点和 Geometry Nodes 图都还在。需要本地 .blend 时,再单独点击下载 .blend。", + "mp3": "../tests/golden/COURSE-FULL-001/video/narration/save-reopen.mp3", + "wav": "../tests/golden/COURSE-FULL-001/video/narration/save-reopen.wav", + "durationMs": 19992 + }, + { + "id": "summary", + "title": "总结与边界", + "narration": "这四块编辑器已经可以完成入门工作流。明确边界:纹理绘制目前是最小二维画笔,合成器只覆盖常用节点,Geometry Nodes 只使用允许列表,Spreadsheet 只读。完整清单和限制写在课程第十五模块。", + "mp3": "../tests/golden/COURSE-FULL-001/video/narration/summary.mp3", + "wav": "../tests/golden/COURSE-FULL-001/video/narration/summary.wav", + "durationMs": 19080 + } + ] +} diff --git a/tests/golden/COURSE-FULL-001/video/timeline.json b/tests/golden/COURSE-FULL-001/video/timeline.json new file mode 100644 index 000000000..e311b624e --- /dev/null +++ b/tests/golden/COURSE-FULL-001/video/timeline.json @@ -0,0 +1,67 @@ +{ + "schemaVersion": 1, + "status": "PASS", + "gpuMode": "hardware", + "durationMs": 198097, + "chapters": [ + { + "id": "intro", + "title": "开场:这几块编辑器现在能做什么", + "startMs": 1998, + "endMs": 23406 + }, + { + "id": "open-studio", + "title": "打开练习工程", + "startMs": 23407, + "endMs": 36988 + }, + { + "id": "uv", + "title": "UV 展开", + "startMs": 36989, + "endMs": 60333 + }, + { + "id": "texture-paint", + "title": "纹理绘制", + "startMs": 60334, + "endMs": 83657 + }, + { + "id": "compositor", + "title": "Compositor 合成", + "startMs": 83658, + "endMs": 109141 + }, + { + "id": "geometry-nodes", + "title": "Geometry Nodes", + "startMs": 109142, + "endMs": 127572 + }, + { + "id": "spreadsheet", + "title": "Spreadsheet 数据检查", + "startMs": 127573, + "endMs": 148806 + }, + { + "id": "save-reopen", + "title": "保存到 OPFS 并重开", + "startMs": 148807, + "endMs": 172801 + }, + { + "id": "summary", + "title": "总结与边界", + "startMs": 172802, + "endMs": 195883 + } + ], + "errors": [], + "screencast": { + "path": "/home/mes123456/workinf_Blender_Wasm/tests/golden/COURSE-FULL-001/video/screencast.webm", + "bytes": 16422241 + } +} diff --git a/tests/golden/COURSE-FULL-001/video/video-report.json b/tests/golden/COURSE-FULL-001/video/video-report.json new file mode 100644 index 000000000..3f3bf1ac8 --- /dev/null +++ b/tests/golden/COURSE-FULL-001/video/video-report.json @@ -0,0 +1,89 @@ +{ + "schemaVersion": 1, + "status": "PASS", + "voice": "zh-CN-XiaoxiaoNeural", + "video": { + "path": "../web/app/public/tutorial/full/video/full-course-editors-720p.mp4", + "sha256": "25a52b9f08ec8a67d9b3e6ff06166425f2992996e35aca687c91e5dfdf67d137", + "bytes": 7569658 + }, + "narration": { + "path": "../tests/golden/COURSE-FULL-001/video/narration.wav", + "sha256": "c025ce95717087b62501a2f06080bf3b31679d2be5352fde71b908378e58176d", + "bytes": 19017356 + }, + "vtt": { + "path": "../web/app/public/tutorial/full/video/re-pbr-lesson.zh-Hans.vtt", + "sha256": "ce90e056846ce2d98d08a96a54b13e1c0df808fd2fd09aaca51376f56646ea93", + "cues": 9 + }, + "poster": { + "path": "../web/app/public/tutorial/full/video/poster.jpg", + "sha256": "a67d1d7c34365205d177e5003daa0ebce2ebbf8f0bcfd27370b7c35cf5de6fe1" + }, + "chapters": [ + { + "id": "intro", + "title": "开场:这几块编辑器现在能做什么", + "startMs": 1998, + "endMs": 19398, + "narration": "这一课把四块新增的编辑器连起来用一遍:UV 展开、纹理绘制、Compositor 合成、Geometry Nodes 与 Spreadsheet 数据检查。所有保存都用浏览器 OPFS,需要本地文件时再手动下载。" + }, + { + "id": "open-studio", + "title": "打开练习工程", + "startMs": 23407, + "endMs": 32983, + "narration": "先打开课程摄影棚,再追加猴头 GLB。状态栏从六个对象变成七个,活动对象是 Suzanne。" + }, + { + "id": "uv", + "title": "UV 展开", + "startMs": 36989, + "endMs": 56333, + "narration": "切到 UV Editing 工作区。桌面 Blender 的 UV 编辑器要求网格处于编辑模式,所以我们显式进入编辑模式。点击全选 UV,等待选择提交,再点击展开所选 UV。橙色点变成展开后的 UV 岛。" + }, + { + "id": "texture-paint", + "title": "纹理绘制", + "startMs": 60334, + "endMs": 79654, + "narration": "回到 Shading 工作区,从 RE 资产库双击砖墙材质,再导入本地贴图作为材质图片。切到 Texture Paint,选择这张图,在画布上画一条白色笔画。松开鼠标后笔画提交,一次笔画只增加一个版本。" + }, + { + "id": "compositor", + "title": "Compositor 合成", + "startMs": 83658, + "endMs": 105138, + "narration": "切到 Compositing。画布默认有一个 Composite 输出。添加 RGB 颜色和曝光节点,把 RGB 连到曝光,再把曝光连到 Composite。把曝光改成一点五,点击执行合成,右侧预览变亮。点击提交图,节点图写入原生 Main。" + }, + { + "id": "geometry-nodes", + "title": "Geometry Nodes", + "startMs": 109142, + "endMs": 123566, + "narration": "选中猴头,切到 Geometry Nodes。添加 Transform 节点,从 Group Input 连到 Transform,再从 Transform 连到 Group Output。点击提交图,网格随修改器更新。" + }, + { + "id": "spreadsheet", + "title": "Spreadsheet 数据检查", + "startMs": 127573, + "endMs": 144805, + "narration": "在同一个工作区下方的 Spreadsheet 里,把数据源切到顶点,可以看到位置和法线列。输入过滤值,只留下匹配的行;列面板可以隐藏或重排列。Spreadsheet 是只读的,不会修改场景。" + }, + { + "id": "save-reopen", + "title": "保存到 OPFS 并重开", + "startMs": 148807, + "endMs": 168799, + "narration": "点击顶栏保存项目,工程写入浏览器 OPFS。刷新页面,从最近项目重新打开。UV、纹理笔画、合成节点和 Geometry Nodes 图都还在。需要本地 .blend 时,再单独点击下载 .blend。" + }, + { + "id": "summary", + "title": "总结与边界", + "startMs": 172802, + "endMs": 191882, + "narration": "这四块编辑器已经可以完成入门工作流。明确边界:纹理绘制目前是最小二维画笔,合成器只覆盖常用节点,Geometry Nodes 只使用允许列表,Spreadsheet 只读。完整清单和限制写在课程第十五模块。" + } + ] +} diff --git a/tools/web/course/capture-full-course.mjs b/tools/web/course/capture-full-course.mjs new file mode 100644 index 000000000..7cbb57513 --- /dev/null +++ b/tools/web/course/capture-full-course.mjs @@ -0,0 +1,380 @@ +#!/usr/bin/env node +/** + * Capture the 15-module "Web Blender full course" screenshots in headed Chromium. + * + * Usage: + * DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \ + * node tools/web/course/capture-full-course.mjs --dev --port 5184 --gpu hardware + * + * Outputs: + * web/app/public/tutorial/full/steps/Mxx-*.jpg + * tests/golden/COURSE-FULL-001/capture-report.json + */ +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/COURSE-FULL-001"); +const STEPS = path.join(REPO, "web/app/public/tutorial/full/steps"); +const STUDIO = path.join(REPO, "web/app/public/tutorial/pbr/studio.blend"); +const GLB = path.join(REPO, "re/猴头.glb"); +const TEXTURE = path.join(REPO, "web/app/public/tutorial/texture-paint/re-texture-base.png"); + +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 port = Number.parseInt(flag("port", "5184"), 10) || 5184; +const baseUrl = `http://127.0.0.1:${port}`; +const gpuMode = flag("gpu", process.env.GPU_MODE ?? "hardware"); +const onlyModules = new Set(args.flatMap((value, index) => value === "--module" && args[index + 1] ? [args[index + 1]] : [])); +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", "--use-angle=swiftshader", +]; + +fs.mkdirSync(OUT, { recursive: true }); +fs.mkdirSync(STEPS, { recursive: true }); + +async function waitForServer(url, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + 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}`); +} + +const domClick = (locator) => locator.evaluate((element) => element.click()); + +async function drag(page, from, to) { + const a = await from.boundingBox(); + const b = await to.boundingBox(); + if (!a || !b) throw new Error("drag endpoints have no box"); + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); + await page.mouse.down(); + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 14 }); + await page.mouse.up(); +} + +async function main() { + await waitForServer(`${baseUrl}/`); + const browser = await chromium.launch({ + headless: false, + executablePath: process.env.CHROME_PATH ?? "/usr/bin/google-chrome", + args: gpuMode === "hardware" ? hardwareArgs : swiftshaderArgs, + }); + const context = await browser.newContext({ viewport: { width: 1600, height: 900 }, acceptDownloads: true }); + const page = await context.newPage(); + const report = []; + const errors = []; + + const shot = async (moduleId, name) => { + const file = path.join(STEPS, `${moduleId}-${name}.jpg`); + await page.screenshot({ path: file, type: "jpeg", quality: 82 }); + return path.relative(path.join(REPO, "web/app/public/tutorial/full"), file); + }; + + const run = async (moduleId, title, action) => { + if (onlyModules.size > 0 && !onlyModules.has(moduleId)) return; + const started = Date.now(); + try { + const images = await action(); + report.push({ moduleId, title, status: "PASS", images, durationMs: Date.now() - started }); + console.log(`${moduleId} PASS ${Date.now() - started}ms`); + } + catch (error) { + const message = error instanceof Error ? `${error.message}\n${error.stack ?? ""}` : String(error); + errors.push({ moduleId, message }); + let diagnostic = null; + try { diagnostic = await shot(moduleId, "error"); } catch { /* ignore */ } + report.push({ moduleId, title, status: "FAIL", error: message, diagnostic, durationMs: Date.now() - started }); + console.log(`${moduleId} FAIL ${message.split("\n")[0]}`); + } + }; + + const workspace = async (name) => { + await domClick(page.locator(".workspace-tabs button", { hasText: name })); + await page.waitForTimeout(500); + }; + + const openStudio = async () => { + await page.goto(`${baseUrl}/?ui=web`); + await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); + await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO); + await page.locator('[data-testid="scene-stats"]').waitFor({ timeout: 120_000 }); + await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); + }; + + const ensureMonkey = async () => { + const stats = await page.getByTestId("scene-stats").innerText(); + if (/Objects 7/.test(stats)) return; + await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB); + await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); + }; + + const enterEdit = async () => { + const mode = page.locator(".mode-chip button").first(); + const label = await mode.innerText().catch(() => ""); + if (/Edit Mode|编辑模式/.test(label)) return; + await domClick(mode); + const item = page.getByRole("menuitemcheckbox", { name: /编辑模式|Edit Mode/ }).first(); + await item.waitFor({ timeout: 15_000 }); + await item.click(); + await page.waitForTimeout(1200); + }; + + await openStudio(); + await ensureMonkey(); + + await run("M01", "界面、工作区、文件打开与保存", async () => { + const images = [await shot("M01", "interface")]; + await domClick(page.getByRole("button", { name: "文件", exact: true })); + await page.waitForTimeout(400); + images.push(await shot("M01", "file-menu")); + await page.keyboard.press("Escape"); + return images; + }); + + await run("M02", "导航、选择与变换", async () => { + await page.locator(".workspace-tabs button", { hasText: "Layout" }).first().waitFor(); + const images = [await shot("M02", "layout")]; + const outlinerRow = page.locator(".outliner-row").first(); + if (await outlinerRow.count()) { await domClick(outlinerRow); await page.waitForTimeout(400); } + images.push(await shot("M02", "selection")); + const search = page.getByRole("button", { name: "操作搜索" }); + await domClick(search); + await page.waitForTimeout(300); + await page.keyboard.type("移动"); + await page.waitForTimeout(500); + images.push(await shot("M02", "operator-search")); + await page.keyboard.press("Escape"); + return images; + }); + + await run("M03", "网格编辑建模", async () => { + await workspace("Modeling"); + const images = [await shot("M03", "workspace")]; + await enterEdit(); + images.push(await shot("M03", "edit-mode")); + const selectAll = page.getByRole("button", { name: /全选|Select All/ }).first(); + if (await selectAll.count()) await domClick(selectAll); + await page.waitForTimeout(400); + images.push(await shot("M03", "mesh-selection")); + return images; + }); + + await run("M04", "修改器 Bevel/Boolean/Mirror/Array", async () => { + await workspace("Layout"); + await ensureMonkey(); + const images = [await shot("M04", "properties")]; + const heading = page.getByText("Modifiers", { exact: true }).first(); + if (await heading.count()) { await heading.scrollIntoViewIfNeeded(); await page.waitForTimeout(400); } + images.push(await shot("M04", "modifier-stack")); + return images; + }); + + await run("M05", "材质与 Shader/PBR 资产库", async () => { + await workspace("Shading"); + await page.waitForTimeout(800); + const images = [await shot("M05", "shader-workspace")]; + const library = page.getByText(/PBR 资产库|PBR Library/).first(); + if (await library.count()) { await library.scrollIntoViewIfNeeded(); await page.waitForTimeout(500); } + images.push(await shot("M05", "asset-library")); + return images; + }); + + await run("M06", "UV 展开与贴图准备", async () => { + await workspace("UV Editing"); + await page.locator(".wasm-uv-editor").waitFor({ timeout: 60_000 }); + await page.locator(".uv-controls").waitFor({ timeout: 60_000 }); + await enterEdit(); + await page.keyboard.press("Escape"); + const images = [await shot("M06", "uv-workspace")]; + const uv = page.locator(".wasm-uv-editor"); + const selectAll = uv.locator("button", { hasText: "全选 UV" }).first(); + if (await selectAll.count()) { + await domClick(selectAll); + await page.waitForFunction(() => (document.querySelector(".wasm-uv-editor")?.textContent ?? "").includes("UV 选择已提交"), null, { timeout: 60_000 }).catch(() => undefined); + } + const unwrap = uv.locator("button", { hasText: "展开所选 UV" }).first(); + if (await unwrap.count()) { + await domClick(unwrap); + await page.waitForFunction(() => (document.querySelector(".wasm-uv-editor")?.textContent ?? "").includes("展开 UV已提交"), null, { timeout: 60_000 }).catch(() => undefined); + } + images.push(await shot("M06", "unwrap")); + return images; + }); + + await run("M07", "Texture Paint 手绘贴图", async () => { + await workspace("Shading"); + await page.locator(".pbr-asset-library").waitFor({ timeout: 60_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, null, { timeout: 60_000 }); + const material = page.locator('[data-library="library:re-pbr-v1"]', { hasText: "black_tiles_wall" }).first(); + if (await material.count()) { + await material.dblclick(); + await page.waitForFunction(() => { + const slot = document.querySelector('select[aria-label^="材质槽"]'); + return slot instanceof HTMLSelectElement && slot.value.includes("material:black_tiles_wall"); + }, null, { timeout: 120_000 }); + } + const fileInput = page.locator('.property-section input[type="file"][accept="image/png,image/jpeg"]').first(); + if (await fileInput.count()) { + await fileInput.setInputFiles(TEXTURE); + await page.waitForFunction(() => Array.from(document.querySelectorAll('select[aria-label="材质图片"] option')).some((option) => option.textContent?.includes("re-texture-base")), null, { timeout: 60_000 }); + } + const materialImage = page.locator('select[aria-label="材质图片"]').first(); + if (await materialImage.count()) await materialImage.selectOption({ label: "re-texture-base" }).catch(() => undefined); + await workspace("Texture Paint"); + const image = page.locator('[data-testid="texture-paint-canvas"]').first(); + await image.waitFor({ timeout: 60_000 }).catch(() => undefined); + const textureSelect = page.locator('.texture-paint-view select[aria-label="纹理图像"]').first(); + if (await textureSelect.count()) { + const options = await textureSelect.locator("option").allTextContents(); + const match = options.find((text) => text.includes("re-texture-base")); + if (match) await textureSelect.selectOption({ label: match }).catch(() => undefined); + await page.waitForTimeout(1200); + } + const images = [await shot("M07", "texture-paint")]; + if (await image.count()) { + const box = await image.boundingBox(); + if (box) { + await page.mouse.move(box.x + box.width * 0.35, box.y + box.height * 0.45); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.62, box.y + box.height * 0.55, { steps: 18 }); + await page.mouse.up(); + await page.waitForTimeout(2000); + } + } + images.push(await shot("M07", "painted-stroke")); + return images; + }); + + await run("M08", "灯光、相机与 PBR 渲染", async () => { + await workspace("Rendering"); + await page.waitForTimeout(1000); + const images = [await shot("M08", "render-workspace")]; + await page.getByText("PBR 渲染练习", { exact: false }).first().scrollIntoViewIfNeeded().catch(() => undefined); + await page.waitForTimeout(400); + images.push(await shot("M08", "pbr-panel")); + return images; + }); + + await run("M09", "渲染结果、诊断与 GLB 导出", async () => { + await workspace("Rendering"); + const render = page.getByRole("button", { name: /渲染 PBR|Render PBR/ }).first(); + const images = []; + if (await render.count()) { + await domClick(render); + await page.waitForTimeout(8000); + } + images.push(await shot("M09", "render-result")); + return images; + }); + + await run("M10", "时间线与关键帧动画", async () => { + await workspace("Animation"); + await page.waitForTimeout(800); + const images = [await shot("M10", "animation-workspace")]; + const key = page.getByRole("button", { name: "插入位置关键帧", exact: true }).first(); + if (await key.count()) { + await domClick(key); + await page.waitForTimeout(800); + } + images.push(await shot("M10", "keyframe")); + return images; + }); + + await run("M11", "Compositor 合成节点", async () => { + await workspace("Compositing"); + const canvas = page.getByTestId("compositor-canvas"); + await canvas.waitFor({ timeout: 60_000 }); + const images = [await shot("M11", "compositor")]; + const addType = canvas.getByLabel("添加节点类型"); + await addType.selectOption("CONSTANT_COLOR"); + await domClick(canvas.getByRole("button", { name: "添加节点", exact: true })); + await page.waitForTimeout(400); + await addType.selectOption("EXPOSURE"); + await domClick(canvas.getByRole("button", { name: "添加节点", exact: true })); + await page.waitForTimeout(500); + images.push(await shot("M11", "nodes-added")); + const source = page.locator('.react-flow__node[data-id="node-constant-color-1"] .react-flow__handle.source').first(); + const target = page.locator('.react-flow__node[data-id="node-exposure-1"] .react-flow__handle.target').first(); + if (await source.count() && await target.count()) await drag(page, source, target); + const target2 = page.locator('.react-flow__node[data-id="node-composite"] .react-flow__handle.target').first(); + const source2 = page.locator('.react-flow__node[data-id="node-exposure-1"] .react-flow__handle.source').first(); + if (await source2.count() && await target2.count()) await drag(page, source2, target2); + await page.waitForTimeout(300); + await domClick(canvas.getByRole("button", { name: "执行合成", exact: true })); + await page.waitForTimeout(800); + images.push(await shot("M11", "executed")); + return images; + }); + + await run("M12", "Geometry Nodes 程序化建模", async () => { + await workspace("Geometry Nodes"); + const canvas = page.getByTestId("geometry-node-canvas"); + await canvas.waitFor({ timeout: 60_000 }); + const images = [await shot("M12", "geometry-nodes")]; + await canvas.getByLabel("添加节点类型").selectOption({ index: 2 }).catch(() => undefined); + const add = canvas.getByRole("button", { name: "添加节点", exact: true }); + if (await add.count()) { await domClick(add); await page.waitForTimeout(500); } + images.push(await shot("M12", "node-added")); + return images; + }); + + await run("M13", "Spreadsheet 数据检查", async () => { + await workspace("Geometry Nodes"); + const sheet = page.getByTestId("spreadsheet-view"); + await sheet.waitFor({ timeout: 60_000 }); + const images = [await shot("M13", "objects")]; + await sheet.getByLabel("数据源").selectOption("vertices"); + await page.waitForTimeout(600); + images.push(await shot("M13", "vertices")); + await sheet.getByLabel("过滤行").fill("0.5"); + await page.waitForTimeout(400); + images.push(await shot("M13", "filtered")); + await sheet.getByLabel("过滤行").fill(""); + return images; + }); + + await run("M14", "保存、重开与异常恢复", async () => { + await domClick(page.locator('button[aria-label="保存项目"]')); + await page.waitForTimeout(2500); + const images = [await shot("M14", "opfs-save")]; + await page.reload(); + await page.locator("main.blender-app").waitFor({ timeout: 120_000 }); + await page.waitForTimeout(1200); + images.push(await shot("M14", "reopen")); + return images; + }); + + await run("M15", "脚本、支持矩阵与能力边界", async () => { + await workspace("Scripting"); + await page.waitForTimeout(1200); + const images = [await shot("M15", "scripting")]; + const python = page.getByText("Python Console", { exact: false }).first(); + if (await python.count()) await python.scrollIntoViewIfNeeded().catch(() => undefined); + images.push(await shot("M15", "console")); + return images; + }); + + await context.close(); + await browser.close(); + fs.writeFileSync(path.join(OUT, "capture-report.json"), JSON.stringify({ schemaVersion: 1, generatedAt: new Date().toISOString(), gpuMode, modules: report, errors }, null, 2) + "\n", "utf8"); + console.log(JSON.stringify({ status: errors.length === 0 ? "PASS" : "PARTIAL", modules: report.length, failures: errors.length, report: path.join(OUT, "capture-report.json") }, null, 2)); + process.exitCode = errors.length === 0 ? 0 : 1; +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/tools/web/course/check-full-course.mjs b/tools/web/course/check-full-course.mjs new file mode 100644 index 000000000..bce10a45a --- /dev/null +++ b/tools/web/course/check-full-course.mjs @@ -0,0 +1,51 @@ +#!/usr/bin/env node +/** + * Validate the generated 15-module course page and its media. + * + * Usage: + * node tools/web/course/check-full-course.mjs + */ +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const REPO = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../../.."); +const DIR = path.join(REPO, "web/app/public/tutorial/full"); +const INDEX = path.join(DIR, "index.html"); +const errors = []; + +if (!fs.existsSync(INDEX)) errors.push("missing index.html"); +const html = fs.existsSync(INDEX) ? fs.readFileSync(INDEX, "utf8") : ""; +const modules = [...html.matchAll(/class="course-module"/g)].length; +if (modules !== 15) errors.push(`expected 15 modules, found ${modules}`); +if (!html.includes("OPFS")) errors.push("course does not mention OPFS"); +if (/ { + if (!src || /^https?:/i.test(src)) return; + const file = src.startsWith("/") ? path.join(REPO, "web/app/public", src.slice(1)) : path.join(DIR, src); + if (!fs.existsSync(file)) errors.push(`missing asset ${src}`); +}; +for (const match of html.matchAll(/]+src="([^"]+)"/g)) checkAsset(match[1]); +for (const match of html.matchAll(/]+src="([^"]+)"/g)) checkAsset(match[1]); +for (const match of html.matchAll(/]+src="([^"]+)"/g)) checkAsset(match[1]); + +const media = []; +for (const match of html.matchAll(/]+src="([^"]+)"/g)) { + const file = match[1].startsWith("/") ? path.join(REPO, "web/app/public", match[1].slice(1)) : path.join(DIR, match[1]); + if (!fs.existsSync(file)) continue; + const probe = JSON.parse(execFileSync("/usr/bin/ffprobe", ["-v", "error", "-show_entries", + "format=duration,size:stream=codec_type,codec_name,width,height", "-of", "json", file], { encoding: "utf8" })); + const video = probe.streams.find((stream) => stream.codec_type === "video") ?? {}; + const audio = probe.streams.find((stream) => stream.codec_type === "audio") ?? {}; + media.push({ src: match[1], duration: Number(probe.format?.duration ?? 0), bytes: Number(probe.format?.size ?? 0), width: video.width, height: video.height, videoCodec: video.codec_name, audioCodec: audio.codec_name }); + if (video.codec_name !== "h264" || audio.codec_name !== "aac") errors.push(`${match[1]} is not H.264/AAC`); + if (video.width !== 1280 || video.height !== 720) errors.push(`${match[1]} is not 1280x720`); +} + +const result = { schemaVersion: 1, status: errors.length === 0 ? "PASS" : "FAIL", modules, videos: media.length, media, errors }; +console.log(JSON.stringify(result, null, 2)); +if (errors.length > 0) process.exitCode = 1; diff --git a/tools/web/course/editors-video-chapters.json b/tools/web/course/editors-video-chapters.json new file mode 100644 index 000000000..5d3425aa0 --- /dev/null +++ b/tools/web/course/editors-video-chapters.json @@ -0,0 +1,51 @@ +{ + "schemaVersion": 1, + "voice": "zh-CN-XiaoxiaoNeural", + "chapters": [ + { + "id": "intro", + "title": "开场:这几块编辑器现在能做什么", + "narration": "这一课把四块新增的编辑器连起来用一遍:UV 展开、纹理绘制、Compositor 合成、Geometry Nodes 与 Spreadsheet 数据检查。所有保存都用浏览器 OPFS,需要本地文件时再手动下载。" + }, + { + "id": "open-studio", + "title": "打开练习工程", + "narration": "先打开课程摄影棚,再追加猴头 GLB。状态栏从六个对象变成七个,活动对象是 Suzanne。" + }, + { + "id": "uv", + "title": "UV 展开", + "narration": "切到 UV Editing 工作区。桌面 Blender 的 UV 编辑器要求网格处于编辑模式,所以我们显式进入编辑模式。点击全选 UV,等待选择提交,再点击展开所选 UV。橙色点变成展开后的 UV 岛。" + }, + { + "id": "texture-paint", + "title": "纹理绘制", + "narration": "回到 Shading 工作区,从 RE 资产库双击砖墙材质,再导入本地贴图作为材质图片。切到 Texture Paint,选择这张图,在画布上画一条白色笔画。松开鼠标后笔画提交,一次笔画只增加一个版本。" + }, + { + "id": "compositor", + "title": "Compositor 合成", + "narration": "切到 Compositing。画布默认有一个 Composite 输出。添加 RGB 颜色和曝光节点,把 RGB 连到曝光,再把曝光连到 Composite。把曝光改成一点五,点击执行合成,右侧预览变亮。点击提交图,节点图写入原生 Main。" + }, + { + "id": "geometry-nodes", + "title": "Geometry Nodes", + "narration": "选中猴头,切到 Geometry Nodes。添加 Transform 节点,从 Group Input 连到 Transform,再从 Transform 连到 Group Output。点击提交图,网格随修改器更新。" + }, + { + "id": "spreadsheet", + "title": "Spreadsheet 数据检查", + "narration": "在同一个工作区下方的 Spreadsheet 里,把数据源切到顶点,可以看到位置和法线列。输入过滤值,只留下匹配的行;列面板可以隐藏或重排列。Spreadsheet 是只读的,不会修改场景。" + }, + { + "id": "save-reopen", + "title": "保存到 OPFS 并重开", + "narration": "点击顶栏保存项目,工程写入浏览器 OPFS。刷新页面,从最近项目重新打开。UV、纹理笔画、合成节点和 Geometry Nodes 图都还在。需要本地 .blend 时,再单独点击下载 .blend。" + }, + { + "id": "summary", + "title": "总结与边界", + "narration": "这四块编辑器已经可以完成入门工作流。明确边界:纹理绘制目前是最小二维画笔,合成器只覆盖常用节点,Geometry Nodes 只使用允许列表,Spreadsheet 只读。完整清单和限制写在课程第十五模块。" + } + ] +} diff --git a/tools/web/course/full-course.json b/tools/web/course/full-course.json new file mode 100644 index 000000000..cbab77b09 --- /dev/null +++ b/tools/web/course/full-course.json @@ -0,0 +1,275 @@ +{ + "schemaVersion": 1, + "title": "Web Blender 全站入门课程", + "subtitle": "从界面到渲染、UV、纹理绘制、合成、Geometry Nodes 与数据检查", + "version": "2026-11-13", + "audience": "第一次使用 Blender 的初学者;在浏览器中完成全部练习。", + "firstLoadNote": "首次从远端打开需要下载约 79 MB 的 wasm,实测 154–193 秒;页面会显示加载进度。之后的加载走浏览器缓存。", + "rules": [ + "平台保存、重开和恢复都使用浏览器 OPFS。点击顶栏“保存项目”只会写入 OPFS,不会自动下载文件。", + "需要本地 .blend 时,必须手动点击顶栏“下载 .blend”或“文件 → 下载 .blend 到本地”。", + "课程截图与练习使用 RE 5000PBR 资产和 re/猴头.glb;所有路径均为仓库内相对路径。", + "本课程只验证 Chromium;建议使用有独立显卡的机器并启用硬件 WebGL。" + ], + "videos": [ + { + "id": "editors-lesson", + "title": "编辑器专课:UV、纹理绘制、Compositor、Geometry Nodes 与 Spreadsheet(约 3 分钟)", + "src": "/tutorial/full/video/full-course-editors-720p.mp4", + "poster": "/tutorial/full/video/full-course-editors-poster.jpg", + "vtt": "/tutorial/full/video/full-course-editors.zh-Hans.vtt", + "note": "M06、M07、M11、M12、M13 的操作录像,带中文配音与章节字幕;结尾演示 OPFS 保存重开。" + }, + { + "id": "render-lesson", + "title": "渲染专课:5000PBR 猴头全流程(约 11 分钟)", + "src": "/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4", + "poster": "/tutorial/pbr/lesson/video/poster.jpg", + "vtt": "/tutorial/pbr/lesson/video/re-pbr-lesson.zh-Hans.vtt", + "note": "M08、M09 的完整操作录像,带中文配音与章节字幕。" + } + ], + "modules": [ + { + "id": "M01", + "title": "界面、工作区、文件打开与保存", + "level": "入门", + "minutes": 6, + "summary": "认识顶栏、工作区标签、三维视口、大纲视图和属性面板;打开练习工程并理解 OPFS 保存。", + "objectives": ["找到并切换工作区", "用“打开 .blend”载入练习工程", "区分保存到 OPFS 与手动下载 .blend"], + "steps": [ + { "text": "在浏览器打开 /?ui=web 。如果是远端地址,先等待底部 wasm 加载完成,状态栏出现 SceneIR。", "image": "steps/M01-interface.jpg" }, + { "text": "顶部蓝色标签是工作区:Layout、Modeling、Sculpting、UV Editing、Texture Paint、Shading、Animation、Rendering、Compositing、Geometry Nodes、Scripting。课程会逐个使用。", "image": "steps/M01-interface.jpg" }, + { "text": "点击顶栏“打开 .blend”(或按 F3 搜索“打开”),选择课程工程 studio.blend。等待状态栏对象数变为 6。", "image": "steps/M01-file-menu.jpg" }, + { "text": "再打开 re/猴头.glb。GLB 会追加到当前场景,对象数从 6 变为 7,活动对象是 Suzanne。", "image": "steps/M01-interface.jpg" }, + { "text": "点击“保存项目”。它写入浏览器 OPFS,并显示保存状态 SUCCEEDED;不会下载文件。需要本地副本时,单独点击“下载 .blend”。", "image": "steps/M01-file-menu.jpg" } + ], + "tips": ["工作区切换只改变布局,不改变场景数据。", "状态栏右侧的引擎状态是判断操作是否真正提交的第一现场。"], + "warnings": ["不要在保存流程中寻找自动下载;下载是独立的手动动作。"] + }, + { + "id": "M02", + "title": "导航、选择与变换", + "level": "入门", + "minutes": 7, + "summary": "在三维视口旋转、平移、缩放;从大纲视图选择对象;用操作搜索找到桌面版同名命令。", + "objectives": ["使用鼠标中键旋转、Shift+中键平移、滚轮缩放", "在大纲视图选择活动对象", "用 F3 搜索并执行操作"], + "steps": [ + { "text": "在 Layout 工作区,按住鼠标中键拖动旋转视角;Shift+中键平移;滚轮缩放。视口右上角的导航球可以恢复轴向视图。", "image": "steps/M02-layout.jpg" }, + { "text": "右侧大纲视图列出场景对象。单击一个对象即可设为活动对象;视口与属性面板会同步更新。", "image": "steps/M02-selection.jpg" }, + { "text": "按 F3 打开操作搜索,输入“移动”,可以看到与桌面 Blender 对齐的命令。选中命令后按提示在视口或属性中完成操作。", "image": "steps/M02-operator-search.jpg" }, + { "text": "练习:选中猴头,在属性面板把位置 Z 改为 0.5,再改回 0;观察视口和状态栏。", "image": "steps/M02-selection.jpg" } + ], + "tips": ["属性面板的数值修改会进入原生 Main,并支持撤销。", "不确定命令在哪时,优先用 F3 搜索,而不是猜菜单位置。"], + "warnings": ["删除、布尔等破坏性操作先在练习工程上做,并记住撤销按钮。"] + }, + { + "id": "M03", + "title": "网格编辑建模", + "level": "基础", + "minutes": 9, + "summary": "进入编辑模式,认识顶点/边/面选择,执行挤出、倒角、细分等常用网格操作。", + "objectives": ["在物体模式与编辑模式之间切换", "切换 1/2/3 选择模式", "执行一次挤出或倒角并撤销"], + "steps": [ + { "text": "切换到 Modeling 工作区。左侧快捷栏提供 Add Cube、Add Monkey、Duplicate、Delete 等对象操作。", "image": "steps/M03-workspace.jpg" }, + { "text": "点击左上角模式按钮(Object Mode),选择“编辑模式”。视口进入 Edit Mode,并显示网格元素。", "image": "steps/M03-edit-mode.jpg" }, + { "text": "在视口上方选择 1 Vertex / 2 Edge / 3 Face,或使用工具栏按钮切换选择模式。点击 Select All 选中全部元素。", "image": "steps/M03-mesh-selection.jpg" }, + { "text": "点击 EXTRUDE 或 BEVEL 执行一次操作;按撤销按钮恢复。注意状态栏是否显示命令已提交。", "image": "steps/M03-mesh-selection.jpg" } + ], + "tips": ["编辑模式的选择集保存在原生 Main,可以撤销。", "面模式下才能执行 TRIANGULATE 和 RECALCULATE NORMALS。"], + "warnings": ["对高面数网格做全选挤出会明显变慢;课程使用 1225 顶点的猴头。"] + }, + { + "id": "M04", + "title": "修改器 Bevel/Boolean/Mirror/Array", + "level": "基础", + "minutes": 8, + "summary": "在属性面板查看并编辑修改器栈;理解修改器是非破坏性的,渲染和导出时才求值。", + "objectives": ["找到修改器面板", "添加/启用/应用一个修改器", "理解修改器与网格编辑的区别"], + "steps": [ + { "text": "选中猴头,在 Layout 工作区查看右侧属性面板。向下滚动到 Modifiers 区域。", "image": "steps/M04-properties.jpg" }, + { "text": "修改器会按顺序求值。点击添加修改器,选择 Bevel、Boolean、Mirror 或 Array;调整参数后视口立即更新。", "image": "steps/M04-modifier-stack.jpg" }, + { "text": "练习:给猴头添加 Bevel,宽度设为 0.01;观察面数变化后撤销。", "image": "steps/M04-modifier-stack.jpg" } + ], + "tips": ["修改器栈顺序会影响结果,Boolean 通常放在最后。", "应用修改器会改变网格数据;课程建议先保留修改器版本。"], + "warnings": ["Boolean 需要封闭网格;非流形网格可能出现破面。"] + }, + { + "id": "M05", + "title": "材质与 Shader/PBR 资产库", + "level": "基础", + "minutes": 10, + "summary": "在 Shading 工作区认识材质槽、节点编辑器和 RE 5000PBR 资产库;双击资产分配材质。", + "objectives": ["切换 Shading 工作区", "从资产库分配一个 PBR 材质", "在材质属性中确认 Base Color 贴图"], + "steps": [ + { "text": "切换到 Shading 工作区。上方是 Shader Editor,右侧是材质属性,左下是 PBR 资产库。", "image": "steps/M05-shader-workspace.jpg" }, + { "text": "在“资产来源”下拉框选择 library:re-pbr-v1,只显示六张 RE 材质卡片。单击只选中,双击才分配。", "image": "steps/M05-asset-library.jpg" }, + { "text": "双击 black_tiles_wall。等待材质槽显示 material:black_tiles_wall,并确认材质球状态为 COMPILED。", "image": "steps/M05-asset-library.jpg" }, + { "text": "在材质属性中检查 Base Color 图片;M07 会在这张图上手绘。", "image": "steps/M05-shader-workspace.jpg" } + ], + "tips": ["资产库缩略图加载需要时间,先等卡片出现再双击。", "同一材质可以分配给多个对象;修改会影响所有使用者。"], + "warnings": ["库链接贴图不能直接被纹理绘制修改;M07 会导入本地派生的 PNG。"] + }, + { + "id": "M06", + "title": "UV 展开与贴图准备", + "level": "进阶", + "minutes": 8, + "summary": "在 UV Editing 工作区进入编辑模式,全选 UV、执行展开并提交;保存重开后 UV 仍然存在。", + "objectives": ["在 ?ui=web 下找到可写 UV 区域", "全选并展开 UV", "保存到 OPFS 后重开确认 UV 未丢失"], + "steps": [ + { "text": "切换到 UV Editing 工作区。左侧是原生 UV 编辑器,右侧是三维视口。桌面 Blender 要求网格处于编辑模式,本课程也显式进入编辑模式。", "image": "steps/M06-uv-workspace.jpg" }, + { "text": "点击“全选 UV”,等待 UV 编辑器显示“UV 选择已提交”。橙色点是选中的 UV。", "image": "steps/M06-uv-workspace.jpg" }, + { "text": "点击“展开所选 UV”,等待显示“展开 UV已提交”。视口和 UV 布局会更新。", "image": "steps/M06-unwrap.jpg" }, + { "text": "点击顶栏“保存项目”写入 OPFS,然后从最近项目重新打开;UV 控件和选择仍然存在。", "image": "steps/M06-unwrap.jpg" } + ], + "tips": ["展开前先标记缝合线,能得到更整齐的 UV 岛。", "课程只使用角度展开;其他方法在 UV 控件中也有入口。"], + "warnings": ["UV 操作需要可写原生 UV 区域;如果提示未提交,先确认已进入编辑模式。"] + }, + { + "id": "M07", + "title": "Texture Paint 手绘贴图", + "level": "进阶", + "minutes": 10, + "summary": "导入本地 Base Color 贴图,在 Texture Paint 工作区用画笔绘制一条笔画,一次提交并撤销。", + "objectives": ["给猴头分配带贴图的材质", "选择图像并调整画笔颜色/大小/强度", "绘制、撤销、保存重开"], + "steps": [ + { "text": "按 M05 分配 black_tiles_wall,并在材质属性中导入课程图片 re-texture-base.png,设为 Base Color 的“材质图片”。", "image": "steps/M07-texture-paint.jpg" }, + { "text": "切换到 Texture Paint 工作区。顶部选择纹理图像,左侧是图像画布,下面是绘制/擦除、颜色、大小、强度。", "image": "steps/M07-texture-paint.jpg" }, + { "text": "在画布上按住左键拖动,画出一条白色笔画。松开后显示“笔画已提交”,并增加一个 Main revision。", "image": "steps/M07-painted-stroke.jpg" }, + { "text": "点击“撤销笔画”恢复原图;再点“重做笔画”。保存到 OPFS 后重开,笔画仍在。", "image": "steps/M07-painted-stroke.jpg" } + ], + "tips": ["一次指针笔画只提交一次,避免每个采样点都写 Main。", "图像越大,单次提交的 PNG 编码越慢;课程使用 1024×1024。"], + "warnings": ["库链接图像会被原生拒绝(cannot modify linked library images);请先导入本地图片。"] + }, + { + "id": "M08", + "title": "灯光、相机与 PBR 渲染", + "level": "进阶", + "minutes": 12, + "summary": "在 Rendering 工作区设置相机、分辨率、曝光与位移,执行显卡 PBR 渲染。", + "objectives": ["设置 1024×1024 场景相机渲染", "理解最终材质视图与教学曝光", "执行渲染并拿到结果"], + "steps": [ + { "text": "切换到 Rendering 工作区。左侧是渲染结果视口,下方是 PBR 渲染练习面板,右侧是场景属性。", "image": "steps/M08-render-workspace.jpg" }, + { "text": "视角预设选择“场景相机对齐”,分辨率 1024×1024,视图“最终材质”,教学曝光 0 EV,位移强度 1×。", "image": "steps/M08-pbr-panel.jpg" }, + { "text": "点击“渲染 PBR”。面板会等待纹理与环境贴图就绪,然后用 GPU 输出成片。", "image": "steps/M08-pbr-panel.jpg" }, + { "text": "结果出现后,可在诊断视图切换 Base Color、Normal、Roughness 检查材质。", "image": "steps/M09-render-result.jpg" } + ], + "tips": ["深色材质只看整体亮度容易误判,切到 Base Color 更可靠。", "完整操作录像见本页顶部“渲染专课”。"], + "warnings": ["渲染依赖硬件 WebGL;SwiftShader 回退会失败并明确报错。"] + }, + { + "id": "M09", + "title": "渲染结果、诊断与 GLB 导出", + "level": "进阶", + "minutes": 8, + "summary": "检查渲染结果尺寸与耗时,下载 PNG,导出 GLB,并核对保存状态。", + "objectives": ["确认渲染结果 1024×1024", "下载 PNG", "导出 GLB 并保存 OPFS"], + "steps": [ + { "text": "渲染完成后,结果视口底部显示分辨率、字节数和 OPFS 引用。确认是 1024×1024。", "image": "steps/M09-render-result.jpg" }, + { "text": "点击“下载 PNG”保存本地成片;这是显式手动下载。", "image": "steps/M09-render-result.jpg" }, + { "text": "点击顶栏“导出 GLB”导出当前场景;导出前确认对象数和活动对象。", "image": "steps/M09-render-result.jpg" }, + { "text": "点击“保存项目”写入 OPFS;重新打开最近项目,确认材质和相机仍在。", "image": "steps/M09-render-result.jpg" } + ], + "tips": ["渲染耗时受分辨率、贴图大小和显卡影响。", "诊断报告按钮会记录失败阶段,便于定位。"], + "warnings": ["导出 GLB 不会包含未打包的外部资源;课程资产已打包。"] + }, + { + "id": "M10", + "title": "时间线与关键帧动画", + "level": "基础", + "minutes": 8, + "summary": "在 Animation 工作区跳帧、插入位置关键帧、播放并保存动作。", + "objectives": ["切换 Animation 工作区", "在指定帧插入位置关键帧", "播放并确认关键帧出现在 Dope Sheet"], + "steps": [ + { "text": "切换到 Animation 工作区。底部是时间线、Dope Sheet 和 NLA 编辑区。", "image": "steps/M10-animation-workspace.jpg" }, + { "text": "把当前帧改为 12,选中猴头,点击“插入位置关键帧”(按钮 Loc)。", "image": "steps/M10-keyframe.jpg" }, + { "text": "再跳到第 48 帧,移动对象后插入第二个位置关键帧;点击播放观察插值。", "image": "steps/M10-keyframe.jpg" }, + { "text": "保存到 OPFS 后重开,Dope Sheet 中仍能看到关键帧。", "image": "steps/M10-keyframe.jpg" } + ], + "tips": ["Loc/Rot/Scale 分别插入位置、旋转和缩放关键帧。", "删除关键帧只删除当前帧的对应通道。"], + "warnings": ["动画工作区右上角的 Camera Perspective 是布局示意;完整相机视图在 Layout 工作区。"] + }, + { + "id": "M11", + "title": "Compositor 合成节点", + "level": "进阶", + "minutes": 12, + "summary": "在 Compositing 工作区创建 RGB 与 Exposure 节点,连线到 Composite,执行并查看合成结果。", + "objectives": ["添加节点并拖拽连线", "编辑节点属性", "执行合成并提交到原生 Main"], + "steps": [ + { "text": "切换到 Compositing 工作区。画布默认有一个 Composite 输出节点,右侧是属性检查器和结果预览。", "image": "steps/M11-compositor.jpg" }, + { "text": "在“添加节点类型”中选择 RGB 颜色,点击“添加节点”;再添加曝光 Exposure。", "image": "steps/M11-nodes-added.jpg" }, + { "text": "从 RGB 的输出端口拖到 Exposure 的输入端口;再从 Exposure 拖到 Composite 的 Image 输入。", "image": "steps/M11-nodes-added.jpg" }, + { "text": "选中 Exposure,把曝光改为 1.5;点击“执行合成”,右侧预览会显示提亮后的颜色。", "image": "steps/M11-executed.jpg" }, + { "text": "点击“提交图”写入原生 CompositorNodeTree;保存 OPFS 后重开,节点、连线、位置和属性都保留。", "image": "steps/M11-executed.jpg" } + ], + "tips": ["Blur、Mix、Alpha Over、Transform 也在同一画布中;Mix 需要 A/B 两路输入。", "Viewer 节点会显示在当前阶段的结果。"], + "warnings": ["IMAGE/RENDER_LAYER 节点可以本地求值,但暂不写入 Main;保存前请移除或替换为 RGB。"] + }, + { + "id": "M12", + "title": "Geometry Nodes 程序化建模", + "level": "进阶", + "minutes": 12, + "summary": "在 Geometry Nodes 工作区添加 Transform 节点,与 Group Input 连线并提交,观察几何更新。", + "objectives": ["读取当前网格的 Geometry Nodes 图", "添加节点并连线", "提交图并保存重开"], + "steps": [ + { "text": "选中猴头,切换到 Geometry Nodes 工作区。上方是节点画布,右侧是节点检查器,下方是 Spreadsheet。", "image": "steps/M12-geometry-nodes.jpg" }, + { "text": "在“添加节点类型”中选择 GeometryNodeTransform,点击“添加节点”。画布出现新节点。", "image": "steps/M12-node-added.jpg" }, + { "text": "从 Group Input 的 Geometry 端口拖到 Transform 的 Geometry 输入;再把 Transform 输出连到 Group Output。", "image": "steps/M12-node-added.jpg" }, + { "text": "在检查器修改平移/缩放默认值,点击“提交图”。网格随修改器更新,版本号增加。", "image": "steps/M12-node-added.jpg" } + ], + "tips": ["课程只使用 allowlist 中的 16 类节点,避免构造无法执行的图。", "提交失败时,检查器会显示具体节点或 socket 错误。"], + "warnings": ["节点位置目前只在当前会话保存;保存重开保留节点和连线,但可能重置布局。"] + }, + { + "id": "M13", + "title": "Spreadsheet 数据检查", + "level": "进阶", + "minutes": 8, + "summary": "用 Spreadsheet 只读表查看对象、顶点、边、面、UV 与几何属性;过滤、列显示/重排并导出 CSV。", + "objectives": ["切换数据源", "过滤顶点并隐藏列", "导出 CSV 且不修改场景"], + "steps": [ + { "text": "在 Geometry Nodes 工作区下方打开 Spreadsheet。默认显示对象表:名称、类型、数据、可见性、位置。", "image": "steps/M13-objects.jpg" }, + { "text": "把数据源切到“顶点 Vertices”,可以看到 Position X/Y/Z 和 Normal 列。", "image": "steps/M13-vertices.jpg" }, + { "text": "在过滤框输入 0.5,只剩包含该值的行;在“列”面板隐藏 index 或调整列顺序。", "image": "steps/M13-filtered.jpg" }, + { "text": "点击“导出 CSV”手动下载当前可见列。Spreadsheet 是只读的,不会产生 Main revision。", "image": "steps/M13-filtered.jpg" } + ], + "tips": ["顶点/边/面/UV 读取当前网格缓冲;属性表列出 position、normal、UVMap 等。", "单表最多 50,000 行,超出会在脚注提示。"], + "warnings": ["Spreadsheet 不读取 GN 修改器求值后的属性域;它显示当前网格缓冲。"] + }, + { + "id": "M14", + "title": "保存、重开与异常恢复", + "level": "基础", + "minutes": 8, + "summary": "使用 OPFS 保存与最近项目恢复;理解恢复检查点、错误提示和手动下载的边界。", + "objectives": ["保存到 OPFS 并确认 SUCCEEDED", "从最近项目重开", "需要本地副本时手动下载"], + "steps": [ + { "text": "点击顶栏“保存项目”。状态栏显示 data-user-action-save-status=SUCCEEDED,表示已写入 OPFS。", "image": "steps/M14-opfs-save.jpg" }, + { "text": "刷新页面。最近项目下拉框出现刚才的工程;选择它并确认“不保存,继续”。", "image": "steps/M14-reopen.jpg" }, + { "text": "重开后检查对象数、活动对象、材质槽、UV 选择和渲染结果是否仍在。", "image": "steps/M14-reopen.jpg" }, + { "text": "如需本地文件,单独点击“下载 .blend”;不要把自动下载当作保存流程的一部分。", "image": "steps/M14-opfs-save.jpg" } + ], + "tips": ["恢复失败时,页面会显示错误阶段;先导出诊断报告再重试。", "OPFS 数据与浏览器配置绑定,清理站点数据会删除工程。"], + "warnings": ["关闭浏览器前确认保存状态;未保存的编辑只在当前会话和恢复检查点中。"] + }, + { + "id": "M15", + "title": "脚本、支持矩阵与能力边界", + "level": "进阶", + "minutes": 7, + "summary": "在 Scripting 工作区查看文本编辑器、Python 控制台、Info 与操作历史;了解已支持与延期的能力。", + "objectives": ["打开 Scripting 工作区", "找到 Python 控制台和 Info", "识别课程未覆盖的限制"], + "steps": [ + { "text": "切换到 Scripting 工作区。上方是文本编辑器,下方左侧是 Python 控制台,右侧是 Info 操作日志。", "image": "steps/M15-scripting.jpg" }, + { "text": "Python 控制台在本机原生会话中执行;Web 会话是否可用取决于当前运行模式。", "image": "steps/M15-console.jpg" }, + { "text": "Info 区域显示最近命令;失败命令会给出错误码和阶段,便于定位。", "image": "steps/M15-console.jpg" }, + { "text": "已知限制:多区域 split/join、完整 Driver 编辑器、完整节点/画笔目录、生产级 UDIM/4K 纹理绘制尚未完成。", "image": "steps/M15-scripting.jpg" } + ], + "tips": ["支持矩阵与最新进度见 docs/status/WEB_BLENDER_FULL_COURSE_20261113.md。", "课程不宣称完整 Blender parity。"], + "warnings": ["脚本可修改 Main;执行前先保存到 OPFS 并保留撤销点。"] + } + ] +} diff --git a/tools/web/course/generate-full-course.mjs b/tools/web/course/generate-full-course.mjs new file mode 100644 index 000000000..ed75cd6e2 --- /dev/null +++ b/tools/web/course/generate-full-course.mjs @@ -0,0 +1,160 @@ +#!/usr/bin/env node +/** + * Generate the static 15-module Web Blender course from full-course.json. + * + * Usage: + * node tools/web/course/generate-full-course.mjs + * + * Outputs: + * web/app/public/tutorial/full/index.html + * web/app/public/tutorial/full/course.css + * web/app/public/tutorial/full/course.json + */ +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; + +const HERE = path.dirname(fileURLToPath(import.meta.url)); +const REPO = path.resolve(HERE, "../../.."); +const DATA = path.join(HERE, "full-course.json"); +const PUBLIC = path.join(REPO, "web/app/public"); +const OUT = path.join(PUBLIC, "tutorial/full"); +fs.mkdirSync(OUT, { recursive: true }); + +const escapeHtml = (value) => String(value) + .replaceAll("&", "&").replaceAll("<", "<").replaceAll(">", ">") + .replaceAll('"', """).replaceAll("'", "'"); + +const course = JSON.parse(fs.readFileSync(DATA, "utf8")); +const missing = []; +const exists = (relative) => { + const file = relative.startsWith("/") + ? path.join(PUBLIC, relative.slice(1)) + : path.join(OUT, relative); + if (!fs.existsSync(file)) missing.push(relative); + return fs.existsSync(file); +}; + +const nav = course.modules.map((module) => `${escapeHtml(module.id)}${escapeHtml(module.title)}`).join("\n "); + +const videos = (course.videos ?? []).map((video) => { + exists(video.src); + const poster = video.poster && exists(video.poster) ? ` poster="${escapeHtml(video.poster)}"` : ""; + const track = video.vtt && exists(video.vtt) ? `` : ""; + return `
+

${escapeHtml(video.title)}

+ +

${escapeHtml(video.note)}

+
`; +}).join("\n "); + +const modules = course.modules.map((module) => { + const steps = module.steps.map((step, index) => { + const image = step.image && exists(step.image) ? `
${escapeHtml(module.title)} 第 ${index + 1} 步截图
${escapeHtml(module.id)} · 步骤 ${index + 1}
` : ""; + return `
  • ${escapeHtml(step.text)}

    ${image}
  • `; + }).join("\n "); + const objectives = module.objectives.map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); + const tips = (module.tips ?? []).map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); + const warnings = (module.warnings ?? []).map((item) => `
  • ${escapeHtml(item)}
  • `).join(""); + return `
    +
    +

    ${escapeHtml(module.id)} · ${escapeHtml(module.level)} · 约 ${module.minutes} 分钟

    +

    ${escapeHtml(module.title)}

    +

    ${escapeHtml(module.summary)}

    +
    +

    本模块目标

      ${objectives}
    +
      + ${steps} +
    + ${tips ? `

    提示

      ${tips}
    ` : ""} + ${warnings ? `

    注意

      ${warnings}
    ` : ""} +
    `; +}).join("\n "); + +const rules = course.rules.map((rule) => `
  • ${escapeHtml(rule)}
  • `).join(""); + +const html = ` + + + + + ${escapeHtml(course.title)} + + + +
    +

    Web Blender · ${escapeHtml(course.version)}

    +

    ${escapeHtml(course.title)}

    +

    ${escapeHtml(course.subtitle)}

    +

    ${escapeHtml(course.audience)}

    +

    ${escapeHtml(course.firstLoadNote)}

    +
    +
    + +
    +
    +

    开始之前

    +
      ${rules}
    +
    + ${videos} + ${modules} +
    +

    课程截图来自本机有头 Chromium + RTX 4060 的实际操作。功能边界与最新进度见 docs/status/WEB_BLENDER_FULL_COURSE_20261113.md。

    +

    返回 Web Blender

    +
    +
    +
    + + +`; + +const css = `:root{color-scheme:dark;--bg:#181818;--panel:#232323;--panel-2:#2b2b2b;--line:#3c3c3c;--text:#e6e6e6;--muted:#a9a9a9;--accent:#6aa1d8;--accent-2:#d8a45a} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--text);font:16px/1.7 "Noto Sans SC",system-ui,-apple-system,"Segoe UI",sans-serif} +a{color:var(--accent)} +.course-hero{padding:52px clamp(20px,5vw,72px) 34px;background:linear-gradient(135deg,#1d2833,#242424 60%,#2a2318);border-bottom:1px solid var(--line)} +.course-kicker{margin:0 0 10px;color:var(--accent-2);font-size:13px;letter-spacing:.12em;text-transform:uppercase} +.course-hero h1{margin:0 0 12px;font-size:clamp(30px,4vw,52px);line-height:1.15} +.course-subtitle{margin:0 0 10px;max-width:900px;color:#c8d8e6;font-size:clamp(16px,2vw,21px)} +.course-audience{margin:0 0 16px;color:var(--muted)} +.course-first-load{margin:0;max-width:900px;padding:10px 14px;border-left:3px solid var(--accent-2);background:#2b2620;color:#e8d4b6} +.course-layout{display:grid;grid-template-columns:260px minmax(0,1fr);gap:0;align-items:start} +.course-nav{position:sticky;top:0;max-height:100vh;overflow:auto;padding:18px 14px;border-right:1px solid var(--line);background:#1d1d1d} +.course-nav strong{display:block;margin:0 0 10px;color:var(--muted);font-size:12px;letter-spacing:.08em} +.course-nav a{display:flex;gap:9px;padding:7px 8px;border-radius:5px;color:#d4d4d4;text-decoration:none;font-size:13px;line-height:1.35} +.course-nav a:hover{background:#2c2c2c} +.course-nav a span{flex:none;color:var(--accent-2);font-family:monospace} +.course-main{min-width:0;padding:26px clamp(18px,4vw,56px) 60px} +.course-rules,.course-video,.course-module{max-width:1180px;margin:0 auto 34px;padding:22px clamp(16px,2.5vw,30px);background:var(--panel);border:1px solid var(--line);border-radius:8px} +.course-rules h2,.course-video h2,.course-module h2{margin-top:0;line-height:1.25} +.course-video video{width:100%;height:auto;background:#000;border-radius:6px} +.course-video p{margin:10px 0 0;color:var(--muted)} +.course-module-id{margin:0 0 4px;color:var(--accent-2);font-size:12px;letter-spacing:.08em;text-transform:uppercase} +.course-summary{margin:0;color:#cfcfcf} +.course-objectives{margin:18px 0} +.course-objectives h3,.course-callout h3{margin:0 0 6px;font-size:15px;color:#cfe0ef} +.course-objectives ul{margin:0;padding-left:22px} +.course-steps{margin:20px 0 0;padding:0;list-style:none;counter-reset:step} +.course-steps>li{counter-increment:step;position:relative;margin:0 0 22px;padding:0 0 0 40px} +.course-steps>li::before{content:counter(step);position:absolute;left:0;top:0;display:grid;place-items:center;width:27px;height:27px;border-radius:50%;background:#36506a;color:#e8f2fb;font-size:13px;font-weight:700} +.course-steps p{margin:2px 0 10px} +.course-steps figure{margin:0} +.course-steps img{display:block;width:100%;height:auto;border:1px solid var(--line);border-radius:6px;background:#111} +.course-steps figcaption{margin-top:5px;color:#8f9aa4;font-size:12px} +.course-callout{margin-top:18px;padding:12px 16px;border-radius:6px;background:var(--panel-2);border-left:3px solid #57708a} +.course-callout ul{margin:0;padding-left:20px} +.course-warnings{border-left-color:#c98e5a;background:#2d2720} +.course-footer{max-width:1180px;margin:0 auto;padding-top:18px;border-top:1px solid var(--line);color:var(--muted);font-size:13px} +@media(max-width:900px){.course-layout{grid-template-columns:1fr}.course-nav{position:static;max-height:none;border-right:0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.course-nav strong{grid-column:1/-1}.course-main{padding-top:18px}} +`; + +fs.mkdirSync(OUT, { recursive: true }); +fs.writeFileSync(path.join(OUT, "index.html"), html, "utf8"); +fs.writeFileSync(path.join(OUT, "course.css"), css, "utf8"); +fs.writeFileSync(path.join(OUT, "course.json"), JSON.stringify(course, null, 2) + "\n", "utf8"); + +console.log(JSON.stringify({ status: missing.length === 0 ? "PASS" : "MISSING_ASSETS", modules: course.modules.length, videos: course.videos.length, missing, output: OUT }, null, 2)); +if (missing.length > 0) process.exitCode = 1; diff --git a/tools/web/course/record-full-course-video.mjs b/tools/web/course/record-full-course-video.mjs new file mode 100644 index 000000000..355ac7df8 --- /dev/null +++ b/tools/web/course/record-full-course-video.mjs @@ -0,0 +1,329 @@ +#!/usr/bin/env node +/** + * Record the narrated "new editors" course video (UV, Texture Paint, + * Compositor, Geometry Nodes, Spreadsheet, OPFS reopen). + * + * Prepare narration first: + * python3 -I tools/web/generate-re-pbr-narration.py --mode prepare \ + * --chapters tools/web/course/editors-video-chapters.json \ + * --out tests/golden/COURSE-FULL-001/video + * + * Record: + * DISPLAY=:1 CHROME_PATH=/usr/bin/google-chrome \ + * node tools/web/course/record-full-course-video.mjs --port 5184 --gpu hardware + * + * Mix: + * python3 -I tools/web/generate-re-pbr-narration.py --mode mix \ + * --out tests/golden/COURSE-FULL-001/video \ + * --video web/app/public/tutorial/full/video/full-course-editors-720p.mp4 + */ +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 STUDIO = path.join(REPO, "web/app/public/tutorial/pbr/studio.blend"); +const GLB = path.join(REPO, "re/猴头.glb"); +const TEXTURE = path.join(REPO, "web/app/public/tutorial/texture-paint/re-texture-base.png"); + +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 port = Number.parseInt(flag("port", "5184"), 10) || 5184; +const baseUrl = `http://127.0.0.1:${port}`; +const OUT = path.resolve(flag("out", path.join(REPO, "tests/golden/COURSE-FULL-001/video"))); +const gpuMode = flag("gpu", process.env.GPU_MODE ?? "hardware"); +const narration = JSON.parse(fs.readFileSync(path.join(OUT, "narration.json"), "utf8")); +const durationOf = (id) => narration.chapters.find((chapter) => chapter.id === id)?.durationMs ?? 6000; +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", +]; +fs.mkdirSync(OUT, { recursive: true }); +fs.rmSync(path.join(OUT, "screencast.webm"), { force: true }); + +async function waitForServer(url, timeoutMs = 60_000) { + const deadline = Date.now() + timeoutMs; + while (Date.now() < deadline) { + 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}`); +} + +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 · 全站入门课程"; + 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 }); + }); +} + +const domClick = (locator) => locator.evaluate((element) => element.click()); + +async function drag(page, from, to) { + const a = await from.boundingBox(); + const b = await to.boundingBox(); + if (!a || !b) throw new Error("drag endpoints have no box"); + await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2, { steps: 10 }); + await page.mouse.down(); + await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 14 }); + await page.mouse.up(); +} + +async function main() { + await waitForServer(`${baseUrl}/`); + const recordingStart = Date.now(); + const timeline = []; + const errors = []; + const browser = await chromium.launch({ + headless: false, + executablePath: process.env.CHROME_PATH ?? "/usr/bin/google-chrome", + args: gpuMode === "hardware" ? hardwareArgs : ["--no-sandbox", "--use-gl=swiftshader", "--enable-unsafe-swiftshader"], + }); + const context = await browser.newContext({ + viewport: { width: 1600, height: 900 }, + acceptDownloads: true, + recordVideo: { dir: OUT, size: { width: 1600, height: 900 } }, + }); + const page = await context.newPage(); + const video = page.video(); + page.on("pageerror", (error) => errors.push(`pageerror: ${error.message}`)); + + const caption = (text) => page.evaluate((value) => window.__lessonSetCaption?.(value), text); + const workspace = async (name) => { + await domClick(page.locator(".workspace-tabs button", { hasText: name })); + await page.waitForTimeout(600); + }; + const chapter = async (id, title, action) => { + await caption(title); + const startMs = Date.now() - recordingStart; + try { + await action(); + } + catch (error) { + errors.push(`chapter ${id}: ${error instanceof Error ? error.message : String(error)}`); + await page.screenshot({ path: path.join(OUT, `failure-${id}.png`) }).catch(() => undefined); + } + const waitMs = Math.max(0, startMs + durationOf(id) + 4000 - (Date.now() - recordingStart)); + if (waitMs > 0) await page.waitForTimeout(waitMs); + timeline.push({ id, title, startMs, endMs: Date.now() - recordingStart }); + console.log(`chapter ${id} ${startMs}ms-${Date.now() - recordingStart}ms`); + }; + + try { + await page.goto(`${baseUrl}/?ui=web`, { waitUntil: "domcontentloaded" }); + await page.locator("main.blender-app").waitFor({ timeout: 90_000 }); + await installOverlay(page); + await page.waitForTimeout(800); + + await chapter("intro", "开场:这几块编辑器现在能做什么", () => page.waitForTimeout(1200)); + await chapter("open-studio", "打开练习工程", async () => { + await page.locator('[data-testid="blend-file-input"]').setInputFiles(STUDIO); + await page.waitForFunction(() => /Objects 6/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); + await page.locator('[data-testid="blend-file-input"]').setInputFiles(GLB); + await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); + }); + await chapter("uv", "UV 展开", async () => { + await workspace("UV Editing"); + await page.locator(".wasm-uv-editor").waitFor({ timeout: 60_000 }); + await page.locator(".uv-controls").waitFor({ timeout: 60_000 }); + await page.waitForFunction(() => (document.querySelector('[data-testid="editor-workflow-context"]')?.textContent ?? "").includes("UV Editing"), null, { timeout: 60_000 }); + const mode = page.locator(".mode-chip button").first(); + if (!/Edit Mode/.test(await mode.innerText().catch(() => ""))) { + await domClick(mode); + const edit = page.getByRole("menuitemcheckbox", { name: /编辑模式/ }).first(); + await edit.waitFor({ timeout: 15_000 }); + await edit.click(); + await page.waitForTimeout(1200); + } + await page.waitForFunction(() => (document.querySelector('[data-testid="engine-status"]')?.textContent ?? "").includes("模式:Edit"), null, { timeout: 60_000 }); + await page.keyboard.press("Escape"); + await page.waitForTimeout(800); + const uv = page.locator(".wasm-uv-editor"); + await domClick(uv.locator("button", { hasText: "全选 UV" }).first()); + await page.waitForFunction(() => (document.querySelector(".wasm-uv-editor")?.textContent ?? "").includes("UV 选择已提交"), null, { timeout: 60_000 }); + await domClick(uv.locator("button", { hasText: "展开所选 UV" }).first()); + await page.waitForFunction(() => (document.querySelector(".wasm-uv-editor")?.textContent ?? "").includes("展开 UV已提交"), null, { timeout: 60_000 }); + }); + await chapter("texture-paint", "纹理绘制", async () => { + const modeButton = page.locator(".mode-chip button").first(); + if (!/Object Mode/.test(await modeButton.innerText().catch(() => ""))) { + await domClick(modeButton); + const objectMode = page.getByRole("menuitemcheckbox", { name: /物体模式/ }).first(); + await objectMode.waitFor({ timeout: 15_000 }); + await objectMode.click(); + await page.waitForTimeout(1200); + } + await page.keyboard.press("Escape"); + await workspace("Shading"); + await page.locator(".pbr-asset-library").waitFor({ timeout: 60_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, null, { timeout: 60_000 }); + const card = page.locator('[data-library="library:re-pbr-v1"]', { hasText: "black_tiles_wall" }).first(); + let assigned = false; + for (let attempt = 0; attempt < 3 && !assigned; attempt += 1) { + await card.dblclick().catch(() => undefined); + assigned = await page.waitForFunction(() => { + const slot = document.querySelector('select[aria-label^="材质槽"]'); + return slot instanceof HTMLSelectElement && slot.value.includes("material:black_tiles_wall"); + }, null, { timeout: 30_000 }).then(() => true).catch(() => false); + if (!assigned) await page.waitForTimeout(1500); + } + if (!assigned) throw new Error("RE material assignment failed"); + await page.locator('.property-section input[type="file"][accept="image/png,image/jpeg"]').first().setInputFiles(TEXTURE); + await page.waitForFunction(() => Array.from(document.querySelectorAll('select[aria-label="材质图片"] option')).some((option) => option.textContent?.includes("re-texture-base")), null, { timeout: 60_000 }); + await page.locator('select[aria-label="材质图片"]').first().selectOption({ label: "re-texture-base" }); + await page.waitForTimeout(1200); + await workspace("Texture Paint"); + const textureSelect = page.locator('.texture-paint-view select[aria-label="纹理图像"]').first(); + if (await textureSelect.count()) { + const options = await textureSelect.locator("option").allTextContents(); + const match = options.find((text) => text.includes("re-texture-base")); + if (match) await textureSelect.selectOption({ label: match }).catch(() => undefined); + await page.waitForTimeout(800); + } + const image = page.locator('[data-testid="texture-paint-canvas"]').first(); + await image.waitFor({ timeout: 60_000 }); + const box = await image.boundingBox(); + if (!box) throw new Error("texture paint canvas has no box"); + await page.mouse.move(box.x + box.width * 0.35, box.y + box.height * 0.45); + await page.mouse.down(); + await page.mouse.move(box.x + box.width * 0.62, box.y + box.height * 0.55, { steps: 20 }); + await page.mouse.up(); + await page.waitForTimeout(1600); + }); + await chapter("compositor", "Compositor 合成", async () => { + await workspace("Compositing"); + const canvas = page.getByTestId("compositor-canvas"); + await canvas.waitFor({ timeout: 60_000 }); + const addType = canvas.getByLabel("添加节点类型"); + await addType.selectOption("CONSTANT_COLOR"); + await domClick(canvas.getByRole("button", { name: "添加节点", exact: true })); + await page.waitForTimeout(500); + await addType.selectOption("EXPOSURE"); + await domClick(canvas.getByRole("button", { name: "添加节点", exact: true })); + await page.waitForTimeout(600); + await drag(page, + page.locator('.react-flow__node[data-id="node-constant-color-1"] .react-flow__handle.source').first(), + page.locator('.react-flow__node[data-id="node-exposure-1"] .react-flow__handle.target').first()); + await drag(page, + page.locator('.react-flow__node[data-id="node-exposure-1"] .react-flow__handle.source').first(), + page.locator('.react-flow__node[data-id="node-composite"] .react-flow__handle.target').first()); + await page.locator('.react-flow__node[data-id="node-exposure-1"]').click(); + const exposure = canvas.locator('.compositor-canvas-inspector input[type="number"]').first(); + await exposure.fill("1.5"); + await exposure.blur(); + await domClick(canvas.getByRole("button", { name: "执行合成", exact: true })); + await page.waitForTimeout(1000); + const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"); + await domClick(canvas.getByRole("button", { name: "提交图", exact: true })); + await page.waitForFunction((expected) => Number(document.querySelector("main.blender-app")?.getAttribute("data-project-snapshot-revision") ?? "0") > expected, before, { timeout: 60_000 }); + }); + await chapter("geometry-nodes", "Geometry Nodes", async () => { + await workspace("Geometry Nodes"); + const canvas = page.getByTestId("geometry-node-canvas"); + await canvas.waitFor({ timeout: 60_000 }); + await canvas.getByLabel("添加节点类型").selectOption({ index: 2 }).catch(() => undefined); + await domClick(canvas.getByRole("button", { name: "添加节点", exact: true })); + await page.waitForTimeout(600); + const source = page.locator('.react-flow__node[data-id="group-input"] .react-flow__handle.source').first(); + const target = page.locator('.react-flow__node[data-id="node-geometrynodetransform"] .react-flow__handle.target').first(); + await drag(page, source, target); + const source2 = page.locator('.react-flow__node[data-id="node-geometrynodetransform"] .react-flow__handle.source').first(); + const target2 = page.locator('.react-flow__node[data-id="group-output"] .react-flow__handle.target').first(); + await drag(page, source2, target2); + const before = Number(await page.locator("main.blender-app").getAttribute("data-project-snapshot-revision") ?? "0"); + await domClick(canvas.getByRole("button", { name: "提交图", exact: true })); + await page.waitForFunction((expected) => Number(document.querySelector("main.blender-app")?.getAttribute("data-project-snapshot-revision") ?? "0") > expected, before, { timeout: 60_000 }); + }); + await chapter("spreadsheet", "Spreadsheet 数据检查", async () => { + const sheet = page.getByTestId("spreadsheet-view"); + await sheet.waitFor({ timeout: 60_000 }); + await sheet.getByLabel("数据源").selectOption("vertices"); + await page.waitForTimeout(600); + await sheet.getByLabel("过滤行").fill("0.5"); + await page.waitForTimeout(600); + await sheet.getByLabel("过滤行").fill(""); + }); + await chapter("save-reopen", "保存到 OPFS 并重开", async () => { + await domClick(page.locator('button[aria-label="保存项目"]')); + await page.waitForFunction(() => document.querySelector("main.blender-app")?.getAttribute("data-user-action-save-status") === "SUCCEEDED", null, { timeout: 60_000 }); + await page.reload(); + await page.locator("main.blender-app").waitFor({ timeout: 90_000 }); + await page.waitForFunction(() => document.querySelectorAll('[data-testid="recent-projects"] option').length > 1, null, { timeout: 60_000 }); + await page.locator('[data-testid="recent-projects"]').selectOption({ index: 1 }); + const discard = page.getByRole("button", { name: "不保存,继续", exact: true }); + if (await discard.count()) await discard.click(); + await page.waitForFunction(() => /Objects 7/.test(document.querySelector('[data-testid="scene-stats"]')?.textContent ?? ""), null, { timeout: 120_000 }); + await installOverlay(page); + }); + await chapter("summary", "总结与边界", async () => { + await workspace("Layout"); + await page.waitForTimeout(1200); + }); + } + catch (error) { + errors.push(`fatal: ${error instanceof Error ? error.stack ?? error.message : String(error)}`); + } + finally { + await context.close().catch(() => undefined); + let screencast = null; + if (video) { + const webm = path.join(OUT, "screencast.webm"); + await video.saveAs(webm).catch((error) => errors.push(`video save: ${error.message}`)); + if (fs.existsSync(webm)) screencast = { path: webm, bytes: fs.statSync(webm).size }; + } + await browser.close().catch(() => undefined); + const report = { + schemaVersion: 1, + status: !errors.length && screencast ? "PASS" : "FAIL", + gpuMode, + durationMs: Date.now() - recordingStart, + chapters: timeline, + errors, + screencast, + }; + fs.writeFileSync(path.join(OUT, "timeline.json"), JSON.stringify(report, null, 2) + "\n", "utf8"); + console.log(JSON.stringify({ status: report.status, durationMs: report.durationMs, chapters: timeline.length, errors: errors.length, video: screencast?.path ?? null }, null, 2)); + if (report.status !== "PASS") process.exitCode = 1; + } +} + +main().catch((error) => { console.error(error); process.exit(1); }); diff --git a/web/app/public/tutorial/full/course.css b/web/app/public/tutorial/full/course.css new file mode 100644 index 000000000..1746843ff --- /dev/null +++ b/web/app/public/tutorial/full/course.css @@ -0,0 +1,38 @@ +:root{color-scheme:dark;--bg:#181818;--panel:#232323;--panel-2:#2b2b2b;--line:#3c3c3c;--text:#e6e6e6;--muted:#a9a9a9;--accent:#6aa1d8;--accent-2:#d8a45a} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--text);font:16px/1.7 "Noto Sans SC",system-ui,-apple-system,"Segoe UI",sans-serif} +a{color:var(--accent)} +.course-hero{padding:52px clamp(20px,5vw,72px) 34px;background:linear-gradient(135deg,#1d2833,#242424 60%,#2a2318);border-bottom:1px solid var(--line)} +.course-kicker{margin:0 0 10px;color:var(--accent-2);font-size:13px;letter-spacing:.12em;text-transform:uppercase} +.course-hero h1{margin:0 0 12px;font-size:clamp(30px,4vw,52px);line-height:1.15} +.course-subtitle{margin:0 0 10px;max-width:900px;color:#c8d8e6;font-size:clamp(16px,2vw,21px)} +.course-audience{margin:0 0 16px;color:var(--muted)} +.course-first-load{margin:0;max-width:900px;padding:10px 14px;border-left:3px solid var(--accent-2);background:#2b2620;color:#e8d4b6} +.course-layout{display:grid;grid-template-columns:260px minmax(0,1fr);gap:0;align-items:start} +.course-nav{position:sticky;top:0;max-height:100vh;overflow:auto;padding:18px 14px;border-right:1px solid var(--line);background:#1d1d1d} +.course-nav strong{display:block;margin:0 0 10px;color:var(--muted);font-size:12px;letter-spacing:.08em} +.course-nav a{display:flex;gap:9px;padding:7px 8px;border-radius:5px;color:#d4d4d4;text-decoration:none;font-size:13px;line-height:1.35} +.course-nav a:hover{background:#2c2c2c} +.course-nav a span{flex:none;color:var(--accent-2);font-family:monospace} +.course-main{min-width:0;padding:26px clamp(18px,4vw,56px) 60px} +.course-rules,.course-video,.course-module{max-width:1180px;margin:0 auto 34px;padding:22px clamp(16px,2.5vw,30px);background:var(--panel);border:1px solid var(--line);border-radius:8px} +.course-rules h2,.course-video h2,.course-module h2{margin-top:0;line-height:1.25} +.course-video video{width:100%;height:auto;background:#000;border-radius:6px} +.course-video p{margin:10px 0 0;color:var(--muted)} +.course-module-id{margin:0 0 4px;color:var(--accent-2);font-size:12px;letter-spacing:.08em;text-transform:uppercase} +.course-summary{margin:0;color:#cfcfcf} +.course-objectives{margin:18px 0} +.course-objectives h3,.course-callout h3{margin:0 0 6px;font-size:15px;color:#cfe0ef} +.course-objectives ul{margin:0;padding-left:22px} +.course-steps{margin:20px 0 0;padding:0;list-style:none;counter-reset:step} +.course-steps>li{counter-increment:step;position:relative;margin:0 0 22px;padding:0 0 0 40px} +.course-steps>li::before{content:counter(step);position:absolute;left:0;top:0;display:grid;place-items:center;width:27px;height:27px;border-radius:50%;background:#36506a;color:#e8f2fb;font-size:13px;font-weight:700} +.course-steps p{margin:2px 0 10px} +.course-steps figure{margin:0} +.course-steps img{display:block;width:100%;height:auto;border:1px solid var(--line);border-radius:6px;background:#111} +.course-steps figcaption{margin-top:5px;color:#8f9aa4;font-size:12px} +.course-callout{margin-top:18px;padding:12px 16px;border-radius:6px;background:var(--panel-2);border-left:3px solid #57708a} +.course-callout ul{margin:0;padding-left:20px} +.course-warnings{border-left-color:#c98e5a;background:#2d2720} +.course-footer{max-width:1180px;margin:0 auto;padding-top:18px;border-top:1px solid var(--line);color:var(--muted);font-size:13px} +@media(max-width:900px){.course-layout{grid-template-columns:1fr}.course-nav{position:static;max-height:none;border-right:0;border-bottom:1px solid var(--line);display:grid;grid-template-columns:repeat(auto-fit,minmax(190px,1fr))}.course-nav strong{grid-column:1/-1}.course-main{padding-top:18px}} diff --git a/web/app/public/tutorial/full/course.json b/web/app/public/tutorial/full/course.json new file mode 100644 index 000000000..f1fe6896e --- /dev/null +++ b/web/app/public/tutorial/full/course.json @@ -0,0 +1,593 @@ +{ + "schemaVersion": 1, + "title": "Web Blender 全站入门课程", + "subtitle": "从界面到渲染、UV、纹理绘制、合成、Geometry Nodes 与数据检查", + "version": "2026-11-13", + "audience": "第一次使用 Blender 的初学者;在浏览器中完成全部练习。", + "firstLoadNote": "首次从远端打开需要下载约 79 MB 的 wasm,实测 154–193 秒;页面会显示加载进度。之后的加载走浏览器缓存。", + "rules": [ + "平台保存、重开和恢复都使用浏览器 OPFS。点击顶栏“保存项目”只会写入 OPFS,不会自动下载文件。", + "需要本地 .blend 时,必须手动点击顶栏“下载 .blend”或“文件 → 下载 .blend 到本地”。", + "课程截图与练习使用 RE 5000PBR 资产和 re/猴头.glb;所有路径均为仓库内相对路径。", + "本课程只验证 Chromium;建议使用有独立显卡的机器并启用硬件 WebGL。" + ], + "videos": [ + { + "id": "editors-lesson", + "title": "编辑器专课:UV、纹理绘制、Compositor、Geometry Nodes 与 Spreadsheet(约 3 分钟)", + "src": "/tutorial/full/video/full-course-editors-720p.mp4", + "poster": "/tutorial/full/video/full-course-editors-poster.jpg", + "vtt": "/tutorial/full/video/full-course-editors.zh-Hans.vtt", + "note": "M06、M07、M11、M12、M13 的操作录像,带中文配音与章节字幕;结尾演示 OPFS 保存重开。" + }, + { + "id": "render-lesson", + "title": "渲染专课:5000PBR 猴头全流程(约 11 分钟)", + "src": "/tutorial/pbr/lesson/video/re-pbr-lesson-720p.mp4", + "poster": "/tutorial/pbr/lesson/video/poster.jpg", + "vtt": "/tutorial/pbr/lesson/video/re-pbr-lesson.zh-Hans.vtt", + "note": "M08、M09 的完整操作录像,带中文配音与章节字幕。" + } + ], + "modules": [ + { + "id": "M01", + "title": "界面、工作区、文件打开与保存", + "level": "入门", + "minutes": 6, + "summary": "认识顶栏、工作区标签、三维视口、大纲视图和属性面板;打开练习工程并理解 OPFS 保存。", + "objectives": [ + "找到并切换工作区", + "用“打开 .blend”载入练习工程", + "区分保存到 OPFS 与手动下载 .blend" + ], + "steps": [ + { + "text": "在浏览器打开 /?ui=web 。如果是远端地址,先等待底部 wasm 加载完成,状态栏出现 SceneIR。", + "image": "steps/M01-interface.jpg" + }, + { + "text": "顶部蓝色标签是工作区:Layout、Modeling、Sculpting、UV Editing、Texture Paint、Shading、Animation、Rendering、Compositing、Geometry Nodes、Scripting。课程会逐个使用。", + "image": "steps/M01-interface.jpg" + }, + { + "text": "点击顶栏“打开 .blend”(或按 F3 搜索“打开”),选择课程工程 studio.blend。等待状态栏对象数变为 6。", + "image": "steps/M01-file-menu.jpg" + }, + { + "text": "再打开 re/猴头.glb。GLB 会追加到当前场景,对象数从 6 变为 7,活动对象是 Suzanne。", + "image": "steps/M01-interface.jpg" + }, + { + "text": "点击“保存项目”。它写入浏览器 OPFS,并显示保存状态 SUCCEEDED;不会下载文件。需要本地副本时,单独点击“下载 .blend”。", + "image": "steps/M01-file-menu.jpg" + } + ], + "tips": [ + "工作区切换只改变布局,不改变场景数据。", + "状态栏右侧的引擎状态是判断操作是否真正提交的第一现场。" + ], + "warnings": [ + "不要在保存流程中寻找自动下载;下载是独立的手动动作。" + ] + }, + { + "id": "M02", + "title": "导航、选择与变换", + "level": "入门", + "minutes": 7, + "summary": "在三维视口旋转、平移、缩放;从大纲视图选择对象;用操作搜索找到桌面版同名命令。", + "objectives": [ + "使用鼠标中键旋转、Shift+中键平移、滚轮缩放", + "在大纲视图选择活动对象", + "用 F3 搜索并执行操作" + ], + "steps": [ + { + "text": "在 Layout 工作区,按住鼠标中键拖动旋转视角;Shift+中键平移;滚轮缩放。视口右上角的导航球可以恢复轴向视图。", + "image": "steps/M02-layout.jpg" + }, + { + "text": "右侧大纲视图列出场景对象。单击一个对象即可设为活动对象;视口与属性面板会同步更新。", + "image": "steps/M02-selection.jpg" + }, + { + "text": "按 F3 打开操作搜索,输入“移动”,可以看到与桌面 Blender 对齐的命令。选中命令后按提示在视口或属性中完成操作。", + "image": "steps/M02-operator-search.jpg" + }, + { + "text": "练习:选中猴头,在属性面板把位置 Z 改为 0.5,再改回 0;观察视口和状态栏。", + "image": "steps/M02-selection.jpg" + } + ], + "tips": [ + "属性面板的数值修改会进入原生 Main,并支持撤销。", + "不确定命令在哪时,优先用 F3 搜索,而不是猜菜单位置。" + ], + "warnings": [ + "删除、布尔等破坏性操作先在练习工程上做,并记住撤销按钮。" + ] + }, + { + "id": "M03", + "title": "网格编辑建模", + "level": "基础", + "minutes": 9, + "summary": "进入编辑模式,认识顶点/边/面选择,执行挤出、倒角、细分等常用网格操作。", + "objectives": [ + "在物体模式与编辑模式之间切换", + "切换 1/2/3 选择模式", + "执行一次挤出或倒角并撤销" + ], + "steps": [ + { + "text": "切换到 Modeling 工作区。左侧快捷栏提供 Add Cube、Add Monkey、Duplicate、Delete 等对象操作。", + "image": "steps/M03-workspace.jpg" + }, + { + "text": "点击左上角模式按钮(Object Mode),选择“编辑模式”。视口进入 Edit Mode,并显示网格元素。", + "image": "steps/M03-edit-mode.jpg" + }, + { + "text": "在视口上方选择 1 Vertex / 2 Edge / 3 Face,或使用工具栏按钮切换选择模式。点击 Select All 选中全部元素。", + "image": "steps/M03-mesh-selection.jpg" + }, + { + "text": "点击 EXTRUDE 或 BEVEL 执行一次操作;按撤销按钮恢复。注意状态栏是否显示命令已提交。", + "image": "steps/M03-mesh-selection.jpg" + } + ], + "tips": [ + "编辑模式的选择集保存在原生 Main,可以撤销。", + "面模式下才能执行 TRIANGULATE 和 RECALCULATE NORMALS。" + ], + "warnings": [ + "对高面数网格做全选挤出会明显变慢;课程使用 1225 顶点的猴头。" + ] + }, + { + "id": "M04", + "title": "修改器 Bevel/Boolean/Mirror/Array", + "level": "基础", + "minutes": 8, + "summary": "在属性面板查看并编辑修改器栈;理解修改器是非破坏性的,渲染和导出时才求值。", + "objectives": [ + "找到修改器面板", + "添加/启用/应用一个修改器", + "理解修改器与网格编辑的区别" + ], + "steps": [ + { + "text": "选中猴头,在 Layout 工作区查看右侧属性面板。向下滚动到 Modifiers 区域。", + "image": "steps/M04-properties.jpg" + }, + { + "text": "修改器会按顺序求值。点击添加修改器,选择 Bevel、Boolean、Mirror 或 Array;调整参数后视口立即更新。", + "image": "steps/M04-modifier-stack.jpg" + }, + { + "text": "练习:给猴头添加 Bevel,宽度设为 0.01;观察面数变化后撤销。", + "image": "steps/M04-modifier-stack.jpg" + } + ], + "tips": [ + "修改器栈顺序会影响结果,Boolean 通常放在最后。", + "应用修改器会改变网格数据;课程建议先保留修改器版本。" + ], + "warnings": [ + "Boolean 需要封闭网格;非流形网格可能出现破面。" + ] + }, + { + "id": "M05", + "title": "材质与 Shader/PBR 资产库", + "level": "基础", + "minutes": 10, + "summary": "在 Shading 工作区认识材质槽、节点编辑器和 RE 5000PBR 资产库;双击资产分配材质。", + "objectives": [ + "切换 Shading 工作区", + "从资产库分配一个 PBR 材质", + "在材质属性中确认 Base Color 贴图" + ], + "steps": [ + { + "text": "切换到 Shading 工作区。上方是 Shader Editor,右侧是材质属性,左下是 PBR 资产库。", + "image": "steps/M05-shader-workspace.jpg" + }, + { + "text": "在“资产来源”下拉框选择 library:re-pbr-v1,只显示六张 RE 材质卡片。单击只选中,双击才分配。", + "image": "steps/M05-asset-library.jpg" + }, + { + "text": "双击 black_tiles_wall。等待材质槽显示 material:black_tiles_wall,并确认材质球状态为 COMPILED。", + "image": "steps/M05-asset-library.jpg" + }, + { + "text": "在材质属性中检查 Base Color 图片;M07 会在这张图上手绘。", + "image": "steps/M05-shader-workspace.jpg" + } + ], + "tips": [ + "资产库缩略图加载需要时间,先等卡片出现再双击。", + "同一材质可以分配给多个对象;修改会影响所有使用者。" + ], + "warnings": [ + "库链接贴图不能直接被纹理绘制修改;M07 会导入本地派生的 PNG。" + ] + }, + { + "id": "M06", + "title": "UV 展开与贴图准备", + "level": "进阶", + "minutes": 8, + "summary": "在 UV Editing 工作区进入编辑模式,全选 UV、执行展开并提交;保存重开后 UV 仍然存在。", + "objectives": [ + "在 ?ui=web 下找到可写 UV 区域", + "全选并展开 UV", + "保存到 OPFS 后重开确认 UV 未丢失" + ], + "steps": [ + { + "text": "切换到 UV Editing 工作区。左侧是原生 UV 编辑器,右侧是三维视口。桌面 Blender 要求网格处于编辑模式,本课程也显式进入编辑模式。", + "image": "steps/M06-uv-workspace.jpg" + }, + { + "text": "点击“全选 UV”,等待 UV 编辑器显示“UV 选择已提交”。橙色点是选中的 UV。", + "image": "steps/M06-uv-workspace.jpg" + }, + { + "text": "点击“展开所选 UV”,等待显示“展开 UV已提交”。视口和 UV 布局会更新。", + "image": "steps/M06-unwrap.jpg" + }, + { + "text": "点击顶栏“保存项目”写入 OPFS,然后从最近项目重新打开;UV 控件和选择仍然存在。", + "image": "steps/M06-unwrap.jpg" + } + ], + "tips": [ + "展开前先标记缝合线,能得到更整齐的 UV 岛。", + "课程只使用角度展开;其他方法在 UV 控件中也有入口。" + ], + "warnings": [ + "UV 操作需要可写原生 UV 区域;如果提示未提交,先确认已进入编辑模式。" + ] + }, + { + "id": "M07", + "title": "Texture Paint 手绘贴图", + "level": "进阶", + "minutes": 10, + "summary": "导入本地 Base Color 贴图,在 Texture Paint 工作区用画笔绘制一条笔画,一次提交并撤销。", + "objectives": [ + "给猴头分配带贴图的材质", + "选择图像并调整画笔颜色/大小/强度", + "绘制、撤销、保存重开" + ], + "steps": [ + { + "text": "按 M05 分配 black_tiles_wall,并在材质属性中导入课程图片 re-texture-base.png,设为 Base Color 的“材质图片”。", + "image": "steps/M07-texture-paint.jpg" + }, + { + "text": "切换到 Texture Paint 工作区。顶部选择纹理图像,左侧是图像画布,下面是绘制/擦除、颜色、大小、强度。", + "image": "steps/M07-texture-paint.jpg" + }, + { + "text": "在画布上按住左键拖动,画出一条白色笔画。松开后显示“笔画已提交”,并增加一个 Main revision。", + "image": "steps/M07-painted-stroke.jpg" + }, + { + "text": "点击“撤销笔画”恢复原图;再点“重做笔画”。保存到 OPFS 后重开,笔画仍在。", + "image": "steps/M07-painted-stroke.jpg" + } + ], + "tips": [ + "一次指针笔画只提交一次,避免每个采样点都写 Main。", + "图像越大,单次提交的 PNG 编码越慢;课程使用 1024×1024。" + ], + "warnings": [ + "库链接图像会被原生拒绝(cannot modify linked library images);请先导入本地图片。" + ] + }, + { + "id": "M08", + "title": "灯光、相机与 PBR 渲染", + "level": "进阶", + "minutes": 12, + "summary": "在 Rendering 工作区设置相机、分辨率、曝光与位移,执行显卡 PBR 渲染。", + "objectives": [ + "设置 1024×1024 场景相机渲染", + "理解最终材质视图与教学曝光", + "执行渲染并拿到结果" + ], + "steps": [ + { + "text": "切换到 Rendering 工作区。左侧是渲染结果视口,下方是 PBR 渲染练习面板,右侧是场景属性。", + "image": "steps/M08-render-workspace.jpg" + }, + { + "text": "视角预设选择“场景相机对齐”,分辨率 1024×1024,视图“最终材质”,教学曝光 0 EV,位移强度 1×。", + "image": "steps/M08-pbr-panel.jpg" + }, + { + "text": "点击“渲染 PBR”。面板会等待纹理与环境贴图就绪,然后用 GPU 输出成片。", + "image": "steps/M08-pbr-panel.jpg" + }, + { + "text": "结果出现后,可在诊断视图切换 Base Color、Normal、Roughness 检查材质。", + "image": "steps/M09-render-result.jpg" + } + ], + "tips": [ + "深色材质只看整体亮度容易误判,切到 Base Color 更可靠。", + "完整操作录像见本页顶部“渲染专课”。" + ], + "warnings": [ + "渲染依赖硬件 WebGL;SwiftShader 回退会失败并明确报错。" + ] + }, + { + "id": "M09", + "title": "渲染结果、诊断与 GLB 导出", + "level": "进阶", + "minutes": 8, + "summary": "检查渲染结果尺寸与耗时,下载 PNG,导出 GLB,并核对保存状态。", + "objectives": [ + "确认渲染结果 1024×1024", + "下载 PNG", + "导出 GLB 并保存 OPFS" + ], + "steps": [ + { + "text": "渲染完成后,结果视口底部显示分辨率、字节数和 OPFS 引用。确认是 1024×1024。", + "image": "steps/M09-render-result.jpg" + }, + { + "text": "点击“下载 PNG”保存本地成片;这是显式手动下载。", + "image": "steps/M09-render-result.jpg" + }, + { + "text": "点击顶栏“导出 GLB”导出当前场景;导出前确认对象数和活动对象。", + "image": "steps/M09-render-result.jpg" + }, + { + "text": "点击“保存项目”写入 OPFS;重新打开最近项目,确认材质和相机仍在。", + "image": "steps/M09-render-result.jpg" + } + ], + "tips": [ + "渲染耗时受分辨率、贴图大小和显卡影响。", + "诊断报告按钮会记录失败阶段,便于定位。" + ], + "warnings": [ + "导出 GLB 不会包含未打包的外部资源;课程资产已打包。" + ] + }, + { + "id": "M10", + "title": "时间线与关键帧动画", + "level": "基础", + "minutes": 8, + "summary": "在 Animation 工作区跳帧、插入位置关键帧、播放并保存动作。", + "objectives": [ + "切换 Animation 工作区", + "在指定帧插入位置关键帧", + "播放并确认关键帧出现在 Dope Sheet" + ], + "steps": [ + { + "text": "切换到 Animation 工作区。底部是时间线、Dope Sheet 和 NLA 编辑区。", + "image": "steps/M10-animation-workspace.jpg" + }, + { + "text": "把当前帧改为 12,选中猴头,点击“插入位置关键帧”(按钮 Loc)。", + "image": "steps/M10-keyframe.jpg" + }, + { + "text": "再跳到第 48 帧,移动对象后插入第二个位置关键帧;点击播放观察插值。", + "image": "steps/M10-keyframe.jpg" + }, + { + "text": "保存到 OPFS 后重开,Dope Sheet 中仍能看到关键帧。", + "image": "steps/M10-keyframe.jpg" + } + ], + "tips": [ + "Loc/Rot/Scale 分别插入位置、旋转和缩放关键帧。", + "删除关键帧只删除当前帧的对应通道。" + ], + "warnings": [ + "动画工作区右上角的 Camera Perspective 是布局示意;完整相机视图在 Layout 工作区。" + ] + }, + { + "id": "M11", + "title": "Compositor 合成节点", + "level": "进阶", + "minutes": 12, + "summary": "在 Compositing 工作区创建 RGB 与 Exposure 节点,连线到 Composite,执行并查看合成结果。", + "objectives": [ + "添加节点并拖拽连线", + "编辑节点属性", + "执行合成并提交到原生 Main" + ], + "steps": [ + { + "text": "切换到 Compositing 工作区。画布默认有一个 Composite 输出节点,右侧是属性检查器和结果预览。", + "image": "steps/M11-compositor.jpg" + }, + { + "text": "在“添加节点类型”中选择 RGB 颜色,点击“添加节点”;再添加曝光 Exposure。", + "image": "steps/M11-nodes-added.jpg" + }, + { + "text": "从 RGB 的输出端口拖到 Exposure 的输入端口;再从 Exposure 拖到 Composite 的 Image 输入。", + "image": "steps/M11-nodes-added.jpg" + }, + { + "text": "选中 Exposure,把曝光改为 1.5;点击“执行合成”,右侧预览会显示提亮后的颜色。", + "image": "steps/M11-executed.jpg" + }, + { + "text": "点击“提交图”写入原生 CompositorNodeTree;保存 OPFS 后重开,节点、连线、位置和属性都保留。", + "image": "steps/M11-executed.jpg" + } + ], + "tips": [ + "Blur、Mix、Alpha Over、Transform 也在同一画布中;Mix 需要 A/B 两路输入。", + "Viewer 节点会显示在当前阶段的结果。" + ], + "warnings": [ + "IMAGE/RENDER_LAYER 节点可以本地求值,但暂不写入 Main;保存前请移除或替换为 RGB。" + ] + }, + { + "id": "M12", + "title": "Geometry Nodes 程序化建模", + "level": "进阶", + "minutes": 12, + "summary": "在 Geometry Nodes 工作区添加 Transform 节点,与 Group Input 连线并提交,观察几何更新。", + "objectives": [ + "读取当前网格的 Geometry Nodes 图", + "添加节点并连线", + "提交图并保存重开" + ], + "steps": [ + { + "text": "选中猴头,切换到 Geometry Nodes 工作区。上方是节点画布,右侧是节点检查器,下方是 Spreadsheet。", + "image": "steps/M12-geometry-nodes.jpg" + }, + { + "text": "在“添加节点类型”中选择 GeometryNodeTransform,点击“添加节点”。画布出现新节点。", + "image": "steps/M12-node-added.jpg" + }, + { + "text": "从 Group Input 的 Geometry 端口拖到 Transform 的 Geometry 输入;再把 Transform 输出连到 Group Output。", + "image": "steps/M12-node-added.jpg" + }, + { + "text": "在检查器修改平移/缩放默认值,点击“提交图”。网格随修改器更新,版本号增加。", + "image": "steps/M12-node-added.jpg" + } + ], + "tips": [ + "课程只使用 allowlist 中的 16 类节点,避免构造无法执行的图。", + "提交失败时,检查器会显示具体节点或 socket 错误。" + ], + "warnings": [ + "节点位置目前只在当前会话保存;保存重开保留节点和连线,但可能重置布局。" + ] + }, + { + "id": "M13", + "title": "Spreadsheet 数据检查", + "level": "进阶", + "minutes": 8, + "summary": "用 Spreadsheet 只读表查看对象、顶点、边、面、UV 与几何属性;过滤、列显示/重排并导出 CSV。", + "objectives": [ + "切换数据源", + "过滤顶点并隐藏列", + "导出 CSV 且不修改场景" + ], + "steps": [ + { + "text": "在 Geometry Nodes 工作区下方打开 Spreadsheet。默认显示对象表:名称、类型、数据、可见性、位置。", + "image": "steps/M13-objects.jpg" + }, + { + "text": "把数据源切到“顶点 Vertices”,可以看到 Position X/Y/Z 和 Normal 列。", + "image": "steps/M13-vertices.jpg" + }, + { + "text": "在过滤框输入 0.5,只剩包含该值的行;在“列”面板隐藏 index 或调整列顺序。", + "image": "steps/M13-filtered.jpg" + }, + { + "text": "点击“导出 CSV”手动下载当前可见列。Spreadsheet 是只读的,不会产生 Main revision。", + "image": "steps/M13-filtered.jpg" + } + ], + "tips": [ + "顶点/边/面/UV 读取当前网格缓冲;属性表列出 position、normal、UVMap 等。", + "单表最多 50,000 行,超出会在脚注提示。" + ], + "warnings": [ + "Spreadsheet 不读取 GN 修改器求值后的属性域;它显示当前网格缓冲。" + ] + }, + { + "id": "M14", + "title": "保存、重开与异常恢复", + "level": "基础", + "minutes": 8, + "summary": "使用 OPFS 保存与最近项目恢复;理解恢复检查点、错误提示和手动下载的边界。", + "objectives": [ + "保存到 OPFS 并确认 SUCCEEDED", + "从最近项目重开", + "需要本地副本时手动下载" + ], + "steps": [ + { + "text": "点击顶栏“保存项目”。状态栏显示 data-user-action-save-status=SUCCEEDED,表示已写入 OPFS。", + "image": "steps/M14-opfs-save.jpg" + }, + { + "text": "刷新页面。最近项目下拉框出现刚才的工程;选择它并确认“不保存,继续”。", + "image": "steps/M14-reopen.jpg" + }, + { + "text": "重开后检查对象数、活动对象、材质槽、UV 选择和渲染结果是否仍在。", + "image": "steps/M14-reopen.jpg" + }, + { + "text": "如需本地文件,单独点击“下载 .blend”;不要把自动下载当作保存流程的一部分。", + "image": "steps/M14-opfs-save.jpg" + } + ], + "tips": [ + "恢复失败时,页面会显示错误阶段;先导出诊断报告再重试。", + "OPFS 数据与浏览器配置绑定,清理站点数据会删除工程。" + ], + "warnings": [ + "关闭浏览器前确认保存状态;未保存的编辑只在当前会话和恢复检查点中。" + ] + }, + { + "id": "M15", + "title": "脚本、支持矩阵与能力边界", + "level": "进阶", + "minutes": 7, + "summary": "在 Scripting 工作区查看文本编辑器、Python 控制台、Info 与操作历史;了解已支持与延期的能力。", + "objectives": [ + "打开 Scripting 工作区", + "找到 Python 控制台和 Info", + "识别课程未覆盖的限制" + ], + "steps": [ + { + "text": "切换到 Scripting 工作区。上方是文本编辑器,下方左侧是 Python 控制台,右侧是 Info 操作日志。", + "image": "steps/M15-scripting.jpg" + }, + { + "text": "Python 控制台在本机原生会话中执行;Web 会话是否可用取决于当前运行模式。", + "image": "steps/M15-console.jpg" + }, + { + "text": "Info 区域显示最近命令;失败命令会给出错误码和阶段,便于定位。", + "image": "steps/M15-console.jpg" + }, + { + "text": "已知限制:多区域 split/join、完整 Driver 编辑器、完整节点/画笔目录、生产级 UDIM/4K 纹理绘制尚未完成。", + "image": "steps/M15-scripting.jpg" + } + ], + "tips": [ + "支持矩阵与最新进度见 docs/status/WEB_BLENDER_FULL_COURSE_20261113.md。", + "课程不宣称完整 Blender parity。" + ], + "warnings": [ + "脚本可修改 Main;执行前先保存到 OPFS 并保留撤销点。" + ] + } + ] +} diff --git a/web/app/public/tutorial/full/index.html b/web/app/public/tutorial/full/index.html new file mode 100644 index 000000000..ebbf47503 --- /dev/null +++ b/web/app/public/tutorial/full/index.html @@ -0,0 +1,299 @@ + + + + + + Web Blender 全站入门课程 + + + +
    +

    Web Blender · 2026-11-13

    +

    Web Blender 全站入门课程

    +

    从界面到渲染、UV、纹理绘制、合成、Geometry Nodes 与数据检查

    +

    第一次使用 Blender 的初学者;在浏览器中完成全部练习。

    +

    首次从远端打开需要下载约 79 MB 的 wasm,实测 154–193 秒;页面会显示加载进度。之后的加载走浏览器缓存。

    +
    +
    + +
    +
    +

    开始之前

    +
    • 平台保存、重开和恢复都使用浏览器 OPFS。点击顶栏“保存项目”只会写入 OPFS,不会自动下载文件。
    • 需要本地 .blend 时,必须手动点击顶栏“下载 .blend”或“文件 → 下载 .blend 到本地”。
    • 课程截图与练习使用 RE 5000PBR 资产和 re/猴头.glb;所有路径均为仓库内相对路径。
    • 本课程只验证 Chromium;建议使用有独立显卡的机器并启用硬件 WebGL。
    +
    +
    +

    编辑器专课:UV、纹理绘制、Compositor、Geometry Nodes 与 Spreadsheet(约 3 分钟)

    + +

    M06、M07、M11、M12、M13 的操作录像,带中文配音与章节字幕;结尾演示 OPFS 保存重开。

    +
    +
    +

    渲染专课:5000PBR 猴头全流程(约 11 分钟)

    + +

    M08、M09 的完整操作录像,带中文配音与章节字幕。

    +
    +
    +
    +

    M01 · 入门 · 约 6 分钟

    +

    界面、工作区、文件打开与保存

    +

    认识顶栏、工作区标签、三维视口、大纲视图和属性面板;打开练习工程并理解 OPFS 保存。

    +
    +

    本模块目标

    • 找到并切换工作区
    • 用“打开 .blend”载入练习工程
    • 区分保存到 OPFS 与手动下载 .blend
    +
      +
    1. 在浏览器打开 /?ui=web 。如果是远端地址,先等待底部 wasm 加载完成,状态栏出现 SceneIR。

      界面、工作区、文件打开与保存 第 1 步截图
      M01 · 步骤 1
    2. +
    3. 顶部蓝色标签是工作区:Layout、Modeling、Sculpting、UV Editing、Texture Paint、Shading、Animation、Rendering、Compositing、Geometry Nodes、Scripting。课程会逐个使用。

      界面、工作区、文件打开与保存 第 2 步截图
      M01 · 步骤 2
    4. +
    5. 点击顶栏“打开 .blend”(或按 F3 搜索“打开”),选择课程工程 studio.blend。等待状态栏对象数变为 6。

      界面、工作区、文件打开与保存 第 3 步截图
      M01 · 步骤 3
    6. +
    7. 再打开 re/猴头.glb。GLB 会追加到当前场景,对象数从 6 变为 7,活动对象是 Suzanne。

      界面、工作区、文件打开与保存 第 4 步截图
      M01 · 步骤 4
    8. +
    9. 点击“保存项目”。它写入浏览器 OPFS,并显示保存状态 SUCCEEDED;不会下载文件。需要本地副本时,单独点击“下载 .blend”。

      界面、工作区、文件打开与保存 第 5 步截图
      M01 · 步骤 5
    10. +
    +

    提示

    • 工作区切换只改变布局,不改变场景数据。
    • 状态栏右侧的引擎状态是判断操作是否真正提交的第一现场。
    +

    注意

    • 不要在保存流程中寻找自动下载;下载是独立的手动动作。
    +
    +
    +
    +

    M02 · 入门 · 约 7 分钟

    +

    导航、选择与变换

    +

    在三维视口旋转、平移、缩放;从大纲视图选择对象;用操作搜索找到桌面版同名命令。

    +
    +

    本模块目标

    • 使用鼠标中键旋转、Shift+中键平移、滚轮缩放
    • 在大纲视图选择活动对象
    • 用 F3 搜索并执行操作
    +
      +
    1. 在 Layout 工作区,按住鼠标中键拖动旋转视角;Shift+中键平移;滚轮缩放。视口右上角的导航球可以恢复轴向视图。

      导航、选择与变换 第 1 步截图
      M02 · 步骤 1
    2. +
    3. 右侧大纲视图列出场景对象。单击一个对象即可设为活动对象;视口与属性面板会同步更新。

      导航、选择与变换 第 2 步截图
      M02 · 步骤 2
    4. +
    5. 按 F3 打开操作搜索,输入“移动”,可以看到与桌面 Blender 对齐的命令。选中命令后按提示在视口或属性中完成操作。

      导航、选择与变换 第 3 步截图
      M02 · 步骤 3
    6. +
    7. 练习:选中猴头,在属性面板把位置 Z 改为 0.5,再改回 0;观察视口和状态栏。

      导航、选择与变换 第 4 步截图
      M02 · 步骤 4
    8. +
    +

    提示

    • 属性面板的数值修改会进入原生 Main,并支持撤销。
    • 不确定命令在哪时,优先用 F3 搜索,而不是猜菜单位置。
    +

    注意

    • 删除、布尔等破坏性操作先在练习工程上做,并记住撤销按钮。
    +
    +
    +
    +

    M03 · 基础 · 约 9 分钟

    +

    网格编辑建模

    +

    进入编辑模式,认识顶点/边/面选择,执行挤出、倒角、细分等常用网格操作。

    +
    +

    本模块目标

    • 在物体模式与编辑模式之间切换
    • 切换 1/2/3 选择模式
    • 执行一次挤出或倒角并撤销
    +
      +
    1. 切换到 Modeling 工作区。左侧快捷栏提供 Add Cube、Add Monkey、Duplicate、Delete 等对象操作。

      网格编辑建模 第 1 步截图
      M03 · 步骤 1
    2. +
    3. 点击左上角模式按钮(Object Mode),选择“编辑模式”。视口进入 Edit Mode,并显示网格元素。

      网格编辑建模 第 2 步截图
      M03 · 步骤 2
    4. +
    5. 在视口上方选择 1 Vertex / 2 Edge / 3 Face,或使用工具栏按钮切换选择模式。点击 Select All 选中全部元素。

      网格编辑建模 第 3 步截图
      M03 · 步骤 3
    6. +
    7. 点击 EXTRUDE 或 BEVEL 执行一次操作;按撤销按钮恢复。注意状态栏是否显示命令已提交。

      网格编辑建模 第 4 步截图
      M03 · 步骤 4
    8. +
    +

    提示

    • 编辑模式的选择集保存在原生 Main,可以撤销。
    • 面模式下才能执行 TRIANGULATE 和 RECALCULATE NORMALS。
    +

    注意

    • 对高面数网格做全选挤出会明显变慢;课程使用 1225 顶点的猴头。
    +
    +
    +
    +

    M04 · 基础 · 约 8 分钟

    +

    修改器 Bevel/Boolean/Mirror/Array

    +

    在属性面板查看并编辑修改器栈;理解修改器是非破坏性的,渲染和导出时才求值。

    +
    +

    本模块目标

    • 找到修改器面板
    • 添加/启用/应用一个修改器
    • 理解修改器与网格编辑的区别
    +
      +
    1. 选中猴头,在 Layout 工作区查看右侧属性面板。向下滚动到 Modifiers 区域。

      修改器 Bevel/Boolean/Mirror/Array 第 1 步截图
      M04 · 步骤 1
    2. +
    3. 修改器会按顺序求值。点击添加修改器,选择 Bevel、Boolean、Mirror 或 Array;调整参数后视口立即更新。

      修改器 Bevel/Boolean/Mirror/Array 第 2 步截图
      M04 · 步骤 2
    4. +
    5. 练习:给猴头添加 Bevel,宽度设为 0.01;观察面数变化后撤销。

      修改器 Bevel/Boolean/Mirror/Array 第 3 步截图
      M04 · 步骤 3
    6. +
    +

    提示

    • 修改器栈顺序会影响结果,Boolean 通常放在最后。
    • 应用修改器会改变网格数据;课程建议先保留修改器版本。
    +

    注意

    • Boolean 需要封闭网格;非流形网格可能出现破面。
    +
    +
    +
    +

    M05 · 基础 · 约 10 分钟

    +

    材质与 Shader/PBR 资产库

    +

    在 Shading 工作区认识材质槽、节点编辑器和 RE 5000PBR 资产库;双击资产分配材质。

    +
    +

    本模块目标

    • 切换 Shading 工作区
    • 从资产库分配一个 PBR 材质
    • 在材质属性中确认 Base Color 贴图
    +
      +
    1. 切换到 Shading 工作区。上方是 Shader Editor,右侧是材质属性,左下是 PBR 资产库。

      材质与 Shader/PBR 资产库 第 1 步截图
      M05 · 步骤 1
    2. +
    3. 在“资产来源”下拉框选择 library:re-pbr-v1,只显示六张 RE 材质卡片。单击只选中,双击才分配。

      材质与 Shader/PBR 资产库 第 2 步截图
      M05 · 步骤 2
    4. +
    5. 双击 black_tiles_wall。等待材质槽显示 material:black_tiles_wall,并确认材质球状态为 COMPILED。

      材质与 Shader/PBR 资产库 第 3 步截图
      M05 · 步骤 3
    6. +
    7. 在材质属性中检查 Base Color 图片;M07 会在这张图上手绘。

      材质与 Shader/PBR 资产库 第 4 步截图
      M05 · 步骤 4
    8. +
    +

    提示

    • 资产库缩略图加载需要时间,先等卡片出现再双击。
    • 同一材质可以分配给多个对象;修改会影响所有使用者。
    +

    注意

    • 库链接贴图不能直接被纹理绘制修改;M07 会导入本地派生的 PNG。
    +
    +
    +
    +

    M06 · 进阶 · 约 8 分钟

    +

    UV 展开与贴图准备

    +

    在 UV Editing 工作区进入编辑模式,全选 UV、执行展开并提交;保存重开后 UV 仍然存在。

    +
    +

    本模块目标

    • 在 ?ui=web 下找到可写 UV 区域
    • 全选并展开 UV
    • 保存到 OPFS 后重开确认 UV 未丢失
    +
      +
    1. 切换到 UV Editing 工作区。左侧是原生 UV 编辑器,右侧是三维视口。桌面 Blender 要求网格处于编辑模式,本课程也显式进入编辑模式。

      UV 展开与贴图准备 第 1 步截图
      M06 · 步骤 1
    2. +
    3. 点击“全选 UV”,等待 UV 编辑器显示“UV 选择已提交”。橙色点是选中的 UV。

      UV 展开与贴图准备 第 2 步截图
      M06 · 步骤 2
    4. +
    5. 点击“展开所选 UV”,等待显示“展开 UV已提交”。视口和 UV 布局会更新。

      UV 展开与贴图准备 第 3 步截图
      M06 · 步骤 3
    6. +
    7. 点击顶栏“保存项目”写入 OPFS,然后从最近项目重新打开;UV 控件和选择仍然存在。

      UV 展开与贴图准备 第 4 步截图
      M06 · 步骤 4
    8. +
    +

    提示

    • 展开前先标记缝合线,能得到更整齐的 UV 岛。
    • 课程只使用角度展开;其他方法在 UV 控件中也有入口。
    +

    注意

    • UV 操作需要可写原生 UV 区域;如果提示未提交,先确认已进入编辑模式。
    +
    +
    +
    +

    M07 · 进阶 · 约 10 分钟

    +

    Texture Paint 手绘贴图

    +

    导入本地 Base Color 贴图,在 Texture Paint 工作区用画笔绘制一条笔画,一次提交并撤销。

    +
    +

    本模块目标

    • 给猴头分配带贴图的材质
    • 选择图像并调整画笔颜色/大小/强度
    • 绘制、撤销、保存重开
    +
      +
    1. 按 M05 分配 black_tiles_wall,并在材质属性中导入课程图片 re-texture-base.png,设为 Base Color 的“材质图片”。

      Texture Paint 手绘贴图 第 1 步截图
      M07 · 步骤 1
    2. +
    3. 切换到 Texture Paint 工作区。顶部选择纹理图像,左侧是图像画布,下面是绘制/擦除、颜色、大小、强度。

      Texture Paint 手绘贴图 第 2 步截图
      M07 · 步骤 2
    4. +
    5. 在画布上按住左键拖动,画出一条白色笔画。松开后显示“笔画已提交”,并增加一个 Main revision。

      Texture Paint 手绘贴图 第 3 步截图
      M07 · 步骤 3
    6. +
    7. 点击“撤销笔画”恢复原图;再点“重做笔画”。保存到 OPFS 后重开,笔画仍在。

      Texture Paint 手绘贴图 第 4 步截图
      M07 · 步骤 4
    8. +
    +

    提示

    • 一次指针笔画只提交一次,避免每个采样点都写 Main。
    • 图像越大,单次提交的 PNG 编码越慢;课程使用 1024×1024。
    +

    注意

    • 库链接图像会被原生拒绝(cannot modify linked library images);请先导入本地图片。
    +
    +
    +
    +

    M08 · 进阶 · 约 12 分钟

    +

    灯光、相机与 PBR 渲染

    +

    在 Rendering 工作区设置相机、分辨率、曝光与位移,执行显卡 PBR 渲染。

    +
    +

    本模块目标

    • 设置 1024×1024 场景相机渲染
    • 理解最终材质视图与教学曝光
    • 执行渲染并拿到结果
    +
      +
    1. 切换到 Rendering 工作区。左侧是渲染结果视口,下方是 PBR 渲染练习面板,右侧是场景属性。

      灯光、相机与 PBR 渲染 第 1 步截图
      M08 · 步骤 1
    2. +
    3. 视角预设选择“场景相机对齐”,分辨率 1024×1024,视图“最终材质”,教学曝光 0 EV,位移强度 1×。

      灯光、相机与 PBR 渲染 第 2 步截图
      M08 · 步骤 2
    4. +
    5. 点击“渲染 PBR”。面板会等待纹理与环境贴图就绪,然后用 GPU 输出成片。

      灯光、相机与 PBR 渲染 第 3 步截图
      M08 · 步骤 3
    6. +
    7. 结果出现后,可在诊断视图切换 Base Color、Normal、Roughness 检查材质。

      灯光、相机与 PBR 渲染 第 4 步截图
      M08 · 步骤 4
    8. +
    +

    提示

    • 深色材质只看整体亮度容易误判,切到 Base Color 更可靠。
    • 完整操作录像见本页顶部“渲染专课”。
    +

    注意

    • 渲染依赖硬件 WebGL;SwiftShader 回退会失败并明确报错。
    +
    +
    +
    +

    M09 · 进阶 · 约 8 分钟

    +

    渲染结果、诊断与 GLB 导出

    +

    检查渲染结果尺寸与耗时,下载 PNG,导出 GLB,并核对保存状态。

    +
    +

    本模块目标

    • 确认渲染结果 1024×1024
    • 下载 PNG
    • 导出 GLB 并保存 OPFS
    +
      +
    1. 渲染完成后,结果视口底部显示分辨率、字节数和 OPFS 引用。确认是 1024×1024。

      渲染结果、诊断与 GLB 导出 第 1 步截图
      M09 · 步骤 1
    2. +
    3. 点击“下载 PNG”保存本地成片;这是显式手动下载。

      渲染结果、诊断与 GLB 导出 第 2 步截图
      M09 · 步骤 2
    4. +
    5. 点击顶栏“导出 GLB”导出当前场景;导出前确认对象数和活动对象。

      渲染结果、诊断与 GLB 导出 第 3 步截图
      M09 · 步骤 3
    6. +
    7. 点击“保存项目”写入 OPFS;重新打开最近项目,确认材质和相机仍在。

      渲染结果、诊断与 GLB 导出 第 4 步截图
      M09 · 步骤 4
    8. +
    +

    提示

    • 渲染耗时受分辨率、贴图大小和显卡影响。
    • 诊断报告按钮会记录失败阶段,便于定位。
    +

    注意

    • 导出 GLB 不会包含未打包的外部资源;课程资产已打包。
    +
    +
    +
    +

    M10 · 基础 · 约 8 分钟

    +

    时间线与关键帧动画

    +

    在 Animation 工作区跳帧、插入位置关键帧、播放并保存动作。

    +
    +

    本模块目标

    • 切换 Animation 工作区
    • 在指定帧插入位置关键帧
    • 播放并确认关键帧出现在 Dope Sheet
    +
      +
    1. 切换到 Animation 工作区。底部是时间线、Dope Sheet 和 NLA 编辑区。

      时间线与关键帧动画 第 1 步截图
      M10 · 步骤 1
    2. +
    3. 把当前帧改为 12,选中猴头,点击“插入位置关键帧”(按钮 Loc)。

      时间线与关键帧动画 第 2 步截图
      M10 · 步骤 2
    4. +
    5. 再跳到第 48 帧,移动对象后插入第二个位置关键帧;点击播放观察插值。

      时间线与关键帧动画 第 3 步截图
      M10 · 步骤 3
    6. +
    7. 保存到 OPFS 后重开,Dope Sheet 中仍能看到关键帧。

      时间线与关键帧动画 第 4 步截图
      M10 · 步骤 4
    8. +
    +

    提示

    • Loc/Rot/Scale 分别插入位置、旋转和缩放关键帧。
    • 删除关键帧只删除当前帧的对应通道。
    +

    注意

    • 动画工作区右上角的 Camera Perspective 是布局示意;完整相机视图在 Layout 工作区。
    +
    +
    +
    +

    M11 · 进阶 · 约 12 分钟

    +

    Compositor 合成节点

    +

    在 Compositing 工作区创建 RGB 与 Exposure 节点,连线到 Composite,执行并查看合成结果。

    +
    +

    本模块目标

    • 添加节点并拖拽连线
    • 编辑节点属性
    • 执行合成并提交到原生 Main
    +
      +
    1. 切换到 Compositing 工作区。画布默认有一个 Composite 输出节点,右侧是属性检查器和结果预览。

      Compositor 合成节点 第 1 步截图
      M11 · 步骤 1
    2. +
    3. 在“添加节点类型”中选择 RGB 颜色,点击“添加节点”;再添加曝光 Exposure。

      Compositor 合成节点 第 2 步截图
      M11 · 步骤 2
    4. +
    5. 从 RGB 的输出端口拖到 Exposure 的输入端口;再从 Exposure 拖到 Composite 的 Image 输入。

      Compositor 合成节点 第 3 步截图
      M11 · 步骤 3
    6. +
    7. 选中 Exposure,把曝光改为 1.5;点击“执行合成”,右侧预览会显示提亮后的颜色。

      Compositor 合成节点 第 4 步截图
      M11 · 步骤 4
    8. +
    9. 点击“提交图”写入原生 CompositorNodeTree;保存 OPFS 后重开,节点、连线、位置和属性都保留。

      Compositor 合成节点 第 5 步截图
      M11 · 步骤 5
    10. +
    +

    提示

    • Blur、Mix、Alpha Over、Transform 也在同一画布中;Mix 需要 A/B 两路输入。
    • Viewer 节点会显示在当前阶段的结果。
    +

    注意

    • IMAGE/RENDER_LAYER 节点可以本地求值,但暂不写入 Main;保存前请移除或替换为 RGB。
    +
    +
    +
    +

    M12 · 进阶 · 约 12 分钟

    +

    Geometry Nodes 程序化建模

    +

    在 Geometry Nodes 工作区添加 Transform 节点,与 Group Input 连线并提交,观察几何更新。

    +
    +

    本模块目标

    • 读取当前网格的 Geometry Nodes 图
    • 添加节点并连线
    • 提交图并保存重开
    +
      +
    1. 选中猴头,切换到 Geometry Nodes 工作区。上方是节点画布,右侧是节点检查器,下方是 Spreadsheet。

      Geometry Nodes 程序化建模 第 1 步截图
      M12 · 步骤 1
    2. +
    3. 在“添加节点类型”中选择 GeometryNodeTransform,点击“添加节点”。画布出现新节点。

      Geometry Nodes 程序化建模 第 2 步截图
      M12 · 步骤 2
    4. +
    5. 从 Group Input 的 Geometry 端口拖到 Transform 的 Geometry 输入;再把 Transform 输出连到 Group Output。

      Geometry Nodes 程序化建模 第 3 步截图
      M12 · 步骤 3
    6. +
    7. 在检查器修改平移/缩放默认值,点击“提交图”。网格随修改器更新,版本号增加。

      Geometry Nodes 程序化建模 第 4 步截图
      M12 · 步骤 4
    8. +
    +

    提示

    • 课程只使用 allowlist 中的 16 类节点,避免构造无法执行的图。
    • 提交失败时,检查器会显示具体节点或 socket 错误。
    +

    注意

    • 节点位置目前只在当前会话保存;保存重开保留节点和连线,但可能重置布局。
    +
    +
    +
    +

    M13 · 进阶 · 约 8 分钟

    +

    Spreadsheet 数据检查

    +

    用 Spreadsheet 只读表查看对象、顶点、边、面、UV 与几何属性;过滤、列显示/重排并导出 CSV。

    +
    +

    本模块目标

    • 切换数据源
    • 过滤顶点并隐藏列
    • 导出 CSV 且不修改场景
    +
      +
    1. 在 Geometry Nodes 工作区下方打开 Spreadsheet。默认显示对象表:名称、类型、数据、可见性、位置。

      Spreadsheet 数据检查 第 1 步截图
      M13 · 步骤 1
    2. +
    3. 把数据源切到“顶点 Vertices”,可以看到 Position X/Y/Z 和 Normal 列。

      Spreadsheet 数据检查 第 2 步截图
      M13 · 步骤 2
    4. +
    5. 在过滤框输入 0.5,只剩包含该值的行;在“列”面板隐藏 index 或调整列顺序。

      Spreadsheet 数据检查 第 3 步截图
      M13 · 步骤 3
    6. +
    7. 点击“导出 CSV”手动下载当前可见列。Spreadsheet 是只读的,不会产生 Main revision。

      Spreadsheet 数据检查 第 4 步截图
      M13 · 步骤 4
    8. +
    +

    提示

    • 顶点/边/面/UV 读取当前网格缓冲;属性表列出 position、normal、UVMap 等。
    • 单表最多 50,000 行,超出会在脚注提示。
    +

    注意

    • Spreadsheet 不读取 GN 修改器求值后的属性域;它显示当前网格缓冲。
    +
    +
    +
    +

    M14 · 基础 · 约 8 分钟

    +

    保存、重开与异常恢复

    +

    使用 OPFS 保存与最近项目恢复;理解恢复检查点、错误提示和手动下载的边界。

    +
    +

    本模块目标

    • 保存到 OPFS 并确认 SUCCEEDED
    • 从最近项目重开
    • 需要本地副本时手动下载
    +
      +
    1. 点击顶栏“保存项目”。状态栏显示 data-user-action-save-status=SUCCEEDED,表示已写入 OPFS。

      保存、重开与异常恢复 第 1 步截图
      M14 · 步骤 1
    2. +
    3. 刷新页面。最近项目下拉框出现刚才的工程;选择它并确认“不保存,继续”。

      保存、重开与异常恢复 第 2 步截图
      M14 · 步骤 2
    4. +
    5. 重开后检查对象数、活动对象、材质槽、UV 选择和渲染结果是否仍在。

      保存、重开与异常恢复 第 3 步截图
      M14 · 步骤 3
    6. +
    7. 如需本地文件,单独点击“下载 .blend”;不要把自动下载当作保存流程的一部分。

      保存、重开与异常恢复 第 4 步截图
      M14 · 步骤 4
    8. +
    +

    提示

    • 恢复失败时,页面会显示错误阶段;先导出诊断报告再重试。
    • OPFS 数据与浏览器配置绑定,清理站点数据会删除工程。
    +

    注意

    • 关闭浏览器前确认保存状态;未保存的编辑只在当前会话和恢复检查点中。
    +
    +
    +
    +

    M15 · 进阶 · 约 7 分钟

    +

    脚本、支持矩阵与能力边界

    +

    在 Scripting 工作区查看文本编辑器、Python 控制台、Info 与操作历史;了解已支持与延期的能力。

    +
    +

    本模块目标

    • 打开 Scripting 工作区
    • 找到 Python 控制台和 Info
    • 识别课程未覆盖的限制
    +
      +
    1. 切换到 Scripting 工作区。上方是文本编辑器,下方左侧是 Python 控制台,右侧是 Info 操作日志。

      脚本、支持矩阵与能力边界 第 1 步截图
      M15 · 步骤 1
    2. +
    3. Python 控制台在本机原生会话中执行;Web 会话是否可用取决于当前运行模式。

      脚本、支持矩阵与能力边界 第 2 步截图
      M15 · 步骤 2
    4. +
    5. Info 区域显示最近命令;失败命令会给出错误码和阶段,便于定位。

      脚本、支持矩阵与能力边界 第 3 步截图
      M15 · 步骤 3
    6. +
    7. 已知限制:多区域 split/join、完整 Driver 编辑器、完整节点/画笔目录、生产级 UDIM/4K 纹理绘制尚未完成。

      脚本、支持矩阵与能力边界 第 4 步截图
      M15 · 步骤 4
    8. +
    +

    提示

    • 支持矩阵与最新进度见 docs/status/WEB_BLENDER_FULL_COURSE_20261113.md。
    • 课程不宣称完整 Blender parity。
    +

    注意

    • 脚本可修改 Main;执行前先保存到 OPFS 并保留撤销点。
    +
    +
    +

    课程截图来自本机有头 Chromium + RTX 4060 的实际操作。功能边界与最新进度见 docs/status/WEB_BLENDER_FULL_COURSE_20261113.md。

    +

    返回 Web Blender

    +
    +
    +
    + + diff --git a/web/app/public/tutorial/full/steps/M01-file-menu.jpg b/web/app/public/tutorial/full/steps/M01-file-menu.jpg new file mode 100644 index 000000000..8f0e3c3a9 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M01-file-menu.jpg differ diff --git a/web/app/public/tutorial/full/steps/M01-interface.jpg b/web/app/public/tutorial/full/steps/M01-interface.jpg new file mode 100644 index 000000000..524a8030b Binary files /dev/null and b/web/app/public/tutorial/full/steps/M01-interface.jpg differ diff --git a/web/app/public/tutorial/full/steps/M02-layout.jpg b/web/app/public/tutorial/full/steps/M02-layout.jpg new file mode 100644 index 000000000..99546db88 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M02-layout.jpg differ diff --git a/web/app/public/tutorial/full/steps/M02-operator-search.jpg b/web/app/public/tutorial/full/steps/M02-operator-search.jpg new file mode 100644 index 000000000..9d5014fd8 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M02-operator-search.jpg differ diff --git a/web/app/public/tutorial/full/steps/M02-selection.jpg b/web/app/public/tutorial/full/steps/M02-selection.jpg new file mode 100644 index 000000000..99546db88 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M02-selection.jpg differ diff --git a/web/app/public/tutorial/full/steps/M03-edit-mode.jpg b/web/app/public/tutorial/full/steps/M03-edit-mode.jpg new file mode 100644 index 000000000..0b9c00f46 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M03-edit-mode.jpg differ diff --git a/web/app/public/tutorial/full/steps/M03-mesh-selection.jpg b/web/app/public/tutorial/full/steps/M03-mesh-selection.jpg new file mode 100644 index 000000000..f3740d68e Binary files /dev/null and b/web/app/public/tutorial/full/steps/M03-mesh-selection.jpg differ diff --git a/web/app/public/tutorial/full/steps/M03-workspace.jpg b/web/app/public/tutorial/full/steps/M03-workspace.jpg new file mode 100644 index 000000000..0b9c00f46 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M03-workspace.jpg differ diff --git a/web/app/public/tutorial/full/steps/M04-modifier-stack.jpg b/web/app/public/tutorial/full/steps/M04-modifier-stack.jpg new file mode 100644 index 000000000..de2c27b20 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M04-modifier-stack.jpg differ diff --git a/web/app/public/tutorial/full/steps/M04-properties.jpg b/web/app/public/tutorial/full/steps/M04-properties.jpg new file mode 100644 index 000000000..de2c27b20 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M04-properties.jpg differ diff --git a/web/app/public/tutorial/full/steps/M05-asset-library.jpg b/web/app/public/tutorial/full/steps/M05-asset-library.jpg new file mode 100644 index 000000000..78427d01a Binary files /dev/null and b/web/app/public/tutorial/full/steps/M05-asset-library.jpg differ diff --git a/web/app/public/tutorial/full/steps/M05-shader-workspace.jpg b/web/app/public/tutorial/full/steps/M05-shader-workspace.jpg new file mode 100644 index 000000000..acb165424 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M05-shader-workspace.jpg differ diff --git a/web/app/public/tutorial/full/steps/M06-unwrap.jpg b/web/app/public/tutorial/full/steps/M06-unwrap.jpg new file mode 100644 index 000000000..06b9590fd Binary files /dev/null and b/web/app/public/tutorial/full/steps/M06-unwrap.jpg differ diff --git a/web/app/public/tutorial/full/steps/M06-uv-workspace.jpg b/web/app/public/tutorial/full/steps/M06-uv-workspace.jpg new file mode 100644 index 000000000..2a7725fbe Binary files /dev/null and b/web/app/public/tutorial/full/steps/M06-uv-workspace.jpg differ diff --git a/web/app/public/tutorial/full/steps/M07-painted-stroke.jpg b/web/app/public/tutorial/full/steps/M07-painted-stroke.jpg new file mode 100644 index 000000000..8333a207a Binary files /dev/null and b/web/app/public/tutorial/full/steps/M07-painted-stroke.jpg differ diff --git a/web/app/public/tutorial/full/steps/M07-texture-paint.jpg b/web/app/public/tutorial/full/steps/M07-texture-paint.jpg new file mode 100644 index 000000000..99934efbf Binary files /dev/null and b/web/app/public/tutorial/full/steps/M07-texture-paint.jpg differ diff --git a/web/app/public/tutorial/full/steps/M08-pbr-panel.jpg b/web/app/public/tutorial/full/steps/M08-pbr-panel.jpg new file mode 100644 index 000000000..1c642c30f Binary files /dev/null and b/web/app/public/tutorial/full/steps/M08-pbr-panel.jpg differ diff --git a/web/app/public/tutorial/full/steps/M08-render-workspace.jpg b/web/app/public/tutorial/full/steps/M08-render-workspace.jpg new file mode 100644 index 000000000..1c642c30f Binary files /dev/null and b/web/app/public/tutorial/full/steps/M08-render-workspace.jpg differ diff --git a/web/app/public/tutorial/full/steps/M09-render-result.jpg b/web/app/public/tutorial/full/steps/M09-render-result.jpg new file mode 100644 index 000000000..8638ba024 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M09-render-result.jpg differ diff --git a/web/app/public/tutorial/full/steps/M10-animation-workspace.jpg b/web/app/public/tutorial/full/steps/M10-animation-workspace.jpg new file mode 100644 index 000000000..14d95bc7e Binary files /dev/null and b/web/app/public/tutorial/full/steps/M10-animation-workspace.jpg differ diff --git a/web/app/public/tutorial/full/steps/M10-keyframe.jpg b/web/app/public/tutorial/full/steps/M10-keyframe.jpg new file mode 100644 index 000000000..82a4a3046 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M10-keyframe.jpg differ diff --git a/web/app/public/tutorial/full/steps/M11-compositor.jpg b/web/app/public/tutorial/full/steps/M11-compositor.jpg new file mode 100644 index 000000000..3fa2bc33b Binary files /dev/null and b/web/app/public/tutorial/full/steps/M11-compositor.jpg differ diff --git a/web/app/public/tutorial/full/steps/M11-executed.jpg b/web/app/public/tutorial/full/steps/M11-executed.jpg new file mode 100644 index 000000000..f81ca847f Binary files /dev/null and b/web/app/public/tutorial/full/steps/M11-executed.jpg differ diff --git a/web/app/public/tutorial/full/steps/M11-nodes-added.jpg b/web/app/public/tutorial/full/steps/M11-nodes-added.jpg new file mode 100644 index 000000000..714e91129 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M11-nodes-added.jpg differ diff --git a/web/app/public/tutorial/full/steps/M12-geometry-nodes.jpg b/web/app/public/tutorial/full/steps/M12-geometry-nodes.jpg new file mode 100644 index 000000000..6dfcfc5ab Binary files /dev/null and b/web/app/public/tutorial/full/steps/M12-geometry-nodes.jpg differ diff --git a/web/app/public/tutorial/full/steps/M12-node-added.jpg b/web/app/public/tutorial/full/steps/M12-node-added.jpg new file mode 100644 index 000000000..de4ffdf1b Binary files /dev/null and b/web/app/public/tutorial/full/steps/M12-node-added.jpg differ diff --git a/web/app/public/tutorial/full/steps/M13-filtered.jpg b/web/app/public/tutorial/full/steps/M13-filtered.jpg new file mode 100644 index 000000000..8ab211c5a Binary files /dev/null and b/web/app/public/tutorial/full/steps/M13-filtered.jpg differ diff --git a/web/app/public/tutorial/full/steps/M13-objects.jpg b/web/app/public/tutorial/full/steps/M13-objects.jpg new file mode 100644 index 000000000..a459fdfdc Binary files /dev/null and b/web/app/public/tutorial/full/steps/M13-objects.jpg differ diff --git a/web/app/public/tutorial/full/steps/M13-vertices.jpg b/web/app/public/tutorial/full/steps/M13-vertices.jpg new file mode 100644 index 000000000..c604d9e56 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M13-vertices.jpg differ diff --git a/web/app/public/tutorial/full/steps/M14-opfs-save.jpg b/web/app/public/tutorial/full/steps/M14-opfs-save.jpg new file mode 100644 index 000000000..fb4f85d98 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M14-opfs-save.jpg differ diff --git a/web/app/public/tutorial/full/steps/M14-reopen.jpg b/web/app/public/tutorial/full/steps/M14-reopen.jpg new file mode 100644 index 000000000..575da5f05 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M14-reopen.jpg differ diff --git a/web/app/public/tutorial/full/steps/M15-console.jpg b/web/app/public/tutorial/full/steps/M15-console.jpg new file mode 100644 index 000000000..41a29c336 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M15-console.jpg differ diff --git a/web/app/public/tutorial/full/steps/M15-scripting.jpg b/web/app/public/tutorial/full/steps/M15-scripting.jpg new file mode 100644 index 000000000..41a29c336 Binary files /dev/null and b/web/app/public/tutorial/full/steps/M15-scripting.jpg differ diff --git a/web/app/public/tutorial/full/video/full-course-editors-720p.mp4 b/web/app/public/tutorial/full/video/full-course-editors-720p.mp4 new file mode 100644 index 000000000..4e96a55f5 Binary files /dev/null and b/web/app/public/tutorial/full/video/full-course-editors-720p.mp4 differ diff --git a/web/app/public/tutorial/full/video/full-course-editors-poster.jpg b/web/app/public/tutorial/full/video/full-course-editors-poster.jpg new file mode 100644 index 000000000..75f32cc8f Binary files /dev/null and b/web/app/public/tutorial/full/video/full-course-editors-poster.jpg differ diff --git a/web/app/public/tutorial/full/video/full-course-editors.zh-Hans.vtt b/web/app/public/tutorial/full/video/full-course-editors.zh-Hans.vtt new file mode 100644 index 000000000..4282d93db --- /dev/null +++ b/web/app/public/tutorial/full/video/full-course-editors.zh-Hans.vtt @@ -0,0 +1,28 @@ +WEBVTT + +00:00:01.998 --> 00:00:19.398 +开场:这几块编辑器现在能做什么:这一课把四块新增的编辑器连起来用一遍:UV 展开、纹理绘制、Compositor 合成、Geometry Nodes 与 Spreadsheet 数据检查。所有保存都用浏览器 OPFS,需要本地文件时再手动下载。 + +00:00:23.407 --> 00:00:32.983 +打开练习工程:先打开课程摄影棚,再追加猴头 GLB。状态栏从六个对象变成七个,活动对象是 Suzanne。 + +00:00:36.989 --> 00:00:56.333 +UV 展开:切到 UV Editing 工作区。桌面 Blender 的 UV 编辑器要求网格处于编辑模式,所以我们显式进入编辑模式。点击全选 UV,等待选择提交,再点击展开所选 UV。橙色点变成展开后的 UV 岛。 + +00:01:00.334 --> 00:01:19.654 +纹理绘制:回到 Shading 工作区,从 RE 资产库双击砖墙材质,再导入本地贴图作为材质图片。切到 Texture Paint,选择这张图,在画布上画一条白色笔画。松开鼠标后笔画提交,一次笔画只增加一个版本。 + +00:01:23.658 --> 00:01:45.138 +Compositor 合成:切到 Compositing。画布默认有一个 Composite 输出。添加 RGB 颜色和曝光节点,把 RGB 连到曝光,再把曝光连到 Composite。把曝光改成一点五,点击执行合成,右侧预览变亮。点击提交图,节点图写入原生 Main。 + +00:01:49.142 --> 00:02:03.566 +Geometry Nodes:选中猴头,切到 Geometry Nodes。添加 Transform 节点,从 Group Input 连到 Transform,再从 Transform 连到 Group Output。点击提交图,网格随修改器更新。 + +00:02:07.573 --> 00:02:24.805 +Spreadsheet 数据检查:在同一个工作区下方的 Spreadsheet 里,把数据源切到顶点,可以看到位置和法线列。输入过滤值,只留下匹配的行;列面板可以隐藏或重排列。Spreadsheet 是只读的,不会修改场景。 + +00:02:28.807 --> 00:02:48.799 +保存到 OPFS 并重开:点击顶栏保存项目,工程写入浏览器 OPFS。刷新页面,从最近项目重新打开。UV、纹理笔画、合成节点和 Geometry Nodes 图都还在。需要本地 .blend 时,再单独点击下载 .blend。 + +00:02:52.802 --> 00:03:11.882 +总结与边界:这四块编辑器已经可以完成入门工作流。明确边界:纹理绘制目前是最小二维画笔,合成器只覆盖常用节点,Geometry Nodes 只使用允许列表,Spreadsheet 只读。完整清单和限制写在课程第十五模块。 diff --git a/web/app/src/app/App.tsx b/web/app/src/app/App.tsx index 1633f032a..75fb07ee0 100644 --- a/web/app/src/app/App.tsx +++ b/web/app/src/app/App.tsx @@ -1260,7 +1260,7 @@ function WorkspaceEditorSurface({ editor, snapshot, engineStatus, onOpen, onSear if (editor === "Info") { return
    {EDITOR_ICONS[editor]} InfoView
    {engineStatus}Scene: {activeSceneFromSnapshot(snapshot)?.name ?? "Scene"}
    ; } - return
    Camera Perspective
    {activeNode?.name ?? "Cube"}
    ; + return
    Camera Perspective · 预览
    {activeNode?.name ?? "Cube"}
    动画工作区示意;完整相机视图请在 Layout 工作区查看。
    ; } interface MeshEditSelection { diff --git a/web/app/src/tutorial/TutorialLanding.tsx b/web/app/src/tutorial/TutorialLanding.tsx index a4b4035f5..37be92aab 100644 --- a/web/app/src/tutorial/TutorialLanding.tsx +++ b/web/app/src/tutorial/TutorialLanding.tsx @@ -38,7 +38,7 @@ export function TutorialLanding({ course }: { course: TutorialCourse }) { return
    - +