用 Claude Code 和 Three.js 构建实用 3D Web 界面
用 Claude Code 与 Three.js 实作3D商品查看器,涵盖resize、dispose、评审提示和业务场景。
Three.js 可以把 WebGL 变得更容易使用,但真正能上线的 3D 页面并不只是一个会旋转的模型。你还要处理相机位置、灯光、canvas 尺寸、移动端 GPU 负载、页面切换后的资源释放,以及模型加载失败时的兜底体验。
Claude Code 很适合生成这些基础代码。问题在于,如果只说“帮我做一个很酷的 3D 效果”,它可能给出一个看起来不错但无法长期维护的 demo。本文用 Vite + React + Three.js 做一个可复制的最小商品查看器,并说明如何让 Claude Code 做实现审查。
先定义3D要解决的问题
在写代码之前,先问清楚用户为什么需要 3D。电商商品查看器需要减少购买前的不确定性,例如颜色、材质、背面结构、尺寸感。数据可视化需要让用户看出分布、聚类或时间变化。教育场景则需要把注意力引导到关键部件,而不是让用户随便旋转。
给 Claude Code 的提示应该包含约束,而不只是风格词。
请用 Vite + React + TypeScript + three 创建一个3D商品查看器。
要求:
- canvas 渲染在父元素中,不直接挂到 document.body
- 跟随父容器 resize
- 使用 OrbitControls 支持旋转和缩放
- unmount 时 dispose geometry、material、renderer、controls
- 移动端 devicePixelRatio 最高限制为2
- 代码可以直接复制到 src/App.tsx 运行
这些约束就是 harness,也就是让智能体安全工作的“脚手架”。它能避免最常见的问题:白屏、拉伸、页面切换后越来越卡、手机发热。具体 API 可以对照 Three.js 官方文档,渲染器设置建议查看 WebGLRenderer。
Vite/React 的最小设置
先不要引入复杂模型、HDR 环境和后期处理。用一个简单 mesh 验证相机、灯光、交互、resize 和 dispose,问题会更容易定位。
npm create vite@latest three-claude-demo -- --template react-ts
cd three-claude-demo
npm i three
npm run dev
flowchart LR
A["React component"] --> B["mount div"]
B --> C["WebGLRenderer canvas"]
C --> D["Scene"]
D --> E["Camera and lights"]
D --> F["Mesh and material"]
C --> G["OrbitControls"]
G --> H["resize and dispose"]
可复制的3D查看器
把下面代码放进 src/App.tsx。它会创建一个产品占位模型,支持旋转、缩放、resize,并在组件卸载时释放 Three.js 资源。
import { useEffect, useRef } from "react";
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";
import "./App.css";
export default function App() {
const mountRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const mount = mountRef.current;
if (!mount) return;
const scene = new THREE.Scene();
scene.background = new THREE.Color(0xf6f7fb);
const camera = new THREE.PerspectiveCamera(45, 1, 0.1, 100);
camera.position.set(3.5, 2.2, 4.5);
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
mount.appendChild(renderer.domElement);
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.minDistance = 2.5;
controls.maxDistance = 8;
controls.target.set(0, 0.4, 0);
scene.add(new THREE.HemisphereLight(0xffffff, 0x7c8594, 1.6));
const keyLight = new THREE.DirectionalLight(0xffffff, 2.4);
keyLight.position.set(3, 5, 4);
keyLight.castShadow = true;
scene.add(keyLight);
const productGeometry = new THREE.BoxGeometry(1.8, 1.2, 1.1, 4, 4, 4);
const productMaterial = new THREE.MeshStandardMaterial({
color: 0x2f6f73,
roughness: 0.42,
metalness: 0.08,
});
const product = new THREE.Mesh(productGeometry, productMaterial);
product.castShadow = true;
product.position.y = 0.75;
scene.add(product);
const floorGeometry = new THREE.CircleGeometry(2.2, 64);
const floorMaterial = new THREE.MeshStandardMaterial({
color: 0xd9dee8,
roughness: 0.7,
});
const floor = new THREE.Mesh(floorGeometry, floorMaterial);
floor.rotation.x = -Math.PI / 2;
floor.receiveShadow = true;
scene.add(floor);
const resize = () => {
const width = mount.clientWidth;
const height = mount.clientHeight;
if (width === 0 || height === 0) return;
camera.aspect = width / height;
camera.updateProjectionMatrix();
renderer.setSize(width, height, false);
};
let frameId = 0;
const clock = new THREE.Clock();
const animate = () => {
const elapsed = clock.getElapsedTime();
product.rotation.y = elapsed * 0.45;
product.rotation.x = Math.sin(elapsed * 0.8) * 0.08;
controls.update();
renderer.render(scene, camera);
frameId = window.requestAnimationFrame(animate);
};
resize();
animate();
window.addEventListener("resize", resize);
return () => {
window.removeEventListener("resize", resize);
window.cancelAnimationFrame(frameId);
controls.dispose();
scene.traverse((object) => {
if (object instanceof THREE.Mesh) {
object.geometry.dispose();
const materials = Array.isArray(object.material)
? object.material
: [object.material];
materials.forEach((material) => material.dispose());
}
});
renderer.dispose();
renderer.domElement.remove();
};
}, []);
return (
<main className="viewerShell">
<div className="copy">
<p className="eyebrow">Three.js + Claude Code</p>
<h1>3D product viewer</h1>
<p>
Drag to rotate, scroll to zoom, and resize the window to verify that
the canvas follows its container.
</p>
</div>
<div ref={mountRef} className="viewerStage" />
</main>
);
}
再加入 src/App.css。注意 .viewerStage 必须有高度,否则 canvas 会像白屏一样什么都不显示。
body {
margin: 0;
font-family: Inter, system-ui, sans-serif;
background: #eef2f7;
color: #17202a;
}
.viewerShell {
min-height: 100vh;
display: grid;
grid-template-columns: minmax(260px, 0.8fr) minmax(320px, 1.2fr);
gap: 32px;
align-items: center;
padding: 40px;
box-sizing: border-box;
}
.copy {
max-width: 520px;
}
.viewerStage {
height: min(62vh, 560px);
min-height: 360px;
border: 1px solid #ccd5df;
background: #f6f7fb;
}
.viewerStage canvas {
display: block;
}
@media (max-width: 760px) {
.viewerShell {
grid-template-columns: 1fr;
padding: 24px;
}
.viewerStage {
min-height: 300px;
}
}
让 Claude Code 做审查
实现之后,不要只让 Claude Code “优化一下”。应该明确让它检查风险。
请审查这个 Three.js React 实现。
检查:
1. canvas 是否跟随父容器尺寸
2. geometry、material、renderer、controls 是否在 unmount 时释放
3. requestAnimationFrame 是否停止
4. devicePixelRatio 是否适合高分屏手机
5. 在 Next.js 或 Astro 的 SSR 环境中是否会因 window/document 报错
6. 相机、灯光和控制方式是否适合商品查看
请给出具体差异和手动测试步骤。
这一步能发现肉眼不容易发现的问题,尤其是 dispose 漏掉造成的内存和 GPU 资源累积。
三个实务场景
| 场景 | 3D的价值 | 交给 Claude Code 的任务 |
|---|---|---|
| 3D商品查看器 | 让用户购买前确认颜色、材质、背面和尺寸感 | OrbitControls、颜色切换、灯光预设、移动端性能检查 |
| 数据可视化 | 展示平面图难以表达的聚类、异常值或时间变化 | 点云、3D柱状图、相机过渡、图例和选择状态 |
| 作品集/教育场景 | 一边旋转对象一边解释关键部件 | 注释标签、部件高亮、引导式相机位置 |
商品查看器的重点不是炫技,而是减少疑虑。数据可视化也不是越立体越好,如果3D让比较更困难,就应该保留2D视图。教育场景则需要“观看顺序”,例如按钮切换视角、说明文字出现时暂停自动旋转。
常见失败和修复
| 失败 | 常见原因 | 修复 |
|---|---|---|
| canvas 白屏 | 父元素高度为0、相机没有对准、没有灯光 | 设置CSS高度,检查 camera position 和 controls target |
| resize 后画面变形 | 只改了canvas尺寸,没有更新camera.aspect | 同时调用 camera.updateProjectionMatrix() 和 renderer.setSize() |
| 页面切换后越来越卡 | geometry/material/renderer/controls 未释放 | unmount 时遍历 scene 并 dispose |
| 手机发热 | pixelRatio过高、阴影太重、模型面数太多 | 限制 pixelRatio,减少阴影和几何细分 |
| SSR 报错 | 服务端渲染阶段访问 window/document | 在 useEffect 或 client-only 组件中初始化 |
调试白屏时,先把复杂模型、环境贴图和后期效果全部移除,只保留一个立方体、一盏灯和明亮背景。这样可以快速判断问题来自布局、相机还是资源加载。
发布前检查与CTA
还要注意,3D不是所有页面都值得加入。如果用户只是想快速比较价格、规格或文字说明,普通表格和图片可能更清楚。适合使用Three.js的场景,通常是“角度、空间、材质或运动”本身就是决策信息。把这个判断写进需求文档,再交给Claude Code实现,能减少为了炫技而增加维护成本的情况。
上线前至少要在目标手机上测试帧率、发热、首次加载体积和失败兜底。还要准备替代图片和文字说明,保证 WebGL 不可用时用户仍能理解内容。Canvas 相关设计可以继续阅读Claude Code Canvas开发指南,动画细节可参考Claude Code动画实现指南。
Claude Code Lab 可以协助审查 3D 商品查看器、WebGL 数据可视化和教育互动场景,也可以为团队提供 Claude Code 训练。咨询时请准备目标设备、框架、模型格式、性能预算和业务目标,这样更容易得到可执行的方案。
实际测试结果
我把本文代码贴入 Vite React TypeScript 项目后,分别在桌面宽度和手机宽度下测试了 resize。给 .viewerStage 明确高度后,避免了常见白屏问题;把 devicePixelRatio 限制到2后,高分屏上的GPU压力更可控;让 Claude Code 审查 dispose 路径,也能更早发现页面切换后的资源泄漏风险。
免费 PDF: Claude Code 速查表
输入邮箱即可获取一页 PDF,整理常用命令、审查习惯和安全工作流。
我们会妥善保护你的信息,不发送垃圾邮件。
把 Claude Code 变成真正能带来结果的工作流
先领取中文说明的免费 PDF,再进入英文商品页选择合适的教材。如果你需要团队落地、流程设计或内容变现支持,也可以直接咨询。
关于作者
Masa
专注 Claude Code 实务流程、团队导入和内容转化的工程师。
相关文章
从Obsidian到CLAUDE.md的Claude Code流程:不再反复解释上下文
把 Obsidian 工作笔记整理成 CLAUDE.md 运行说明,让 Claude Code 每次都带着正确上下文开始。
Claude Code 收入 CTA 路由:从文章分流到 PDF、Gumroad 与咨询
用 Claude Code 按读者意图把文章流量分到免费 PDF、Gumroad 教材或咨询入口。
Claude Code 团队交接规则: 把审查证据、权限、回滚和收入路径一起交付
面向团队的 Claude Code 交接格式: 证据、权限、回滚、免费 PDF、Gumroad 与咨询路径都要可审查。