阅读视图
Three.js 曲线应用详解
Three.js CSS2D渲染器实现3D标签效果
概述
本文将详细介绍如何使用 Three.js 的 CSS2DRenderer 来在 3D 场景中添加 HTML 标签。CSS2DRenderer 是 Three.js 提供的一种特殊的渲染器,它允许我们在 3D 对象上叠加 HTML 元素,非常适合创建标签、标注和信息展示等效果。
准备工作
首先,我们需要引入必要的 Three.js 库和 CSS2D 渲染器模块:
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import {
CSS2DRenderer,
CSS2DObject,
} from "three/examples/jsm/renderers/CSS2DRenderer.js";
场景初始化
首先,我们需要创建一个基本的 Three.js 场景:
let camera, scene, renderer, labelRenderer;
const clock = new THREE.Clock();
const textureLoader = new THREE.TextureLoader();
let moon;
let chinaLabel;
const raycaster = new THREE.Raycaster();
function init() {
const EARTH_RADIUS = 1;
const MOON_RADIUS = 0.27;
camera = new THREE.PerspectiveCamera(
45,
window.innerWidth / window.innerHeight,
0.1,
200
);
camera.position.set(0, 5, -10);
scene = new THREE.Scene();
}
光照设置
添加适当的光照使场景更真实:
const dirLight = new THREE.DirectionalLight(0xffffff);
dirLight.position.set(0, 0, 1);
scene.add(dirLight);
const light = new THREE.AmbientLight(0xffffff, 0.5); // soft white light
scene.add(light);
创建地球模型
使用纹理贴图创建地球模型:
const earthGeometry = new THREE.SphereGeometry(EARTH_RADIUS, 16, 16);
const earthMaterial = new THREE.MeshPhongMaterial({
specular: 0x333333,
shininess: 5,
map: textureLoader.load("textures/planets/earth_atmos_2048.jpg"),
specularMap: textureLoader.load("textures/planets/earth_specular_2048.jpg"),
normalMap: textureLoader.load("textures/planets/earth_normal_2048.jpg"),
normalScale: new THREE.Vector2(0.85, 0.85),
});
const earth = new THREE.Mesh(earthGeometry, earthMaterial);
scene.add(earth);
创建月球模型
同样地,创建月球模型:
const moonGeometry = new THREE.SphereGeometry(MOON_RADIUS, 16, 16);
const moonMaterial = new THREE.MeshPhongMaterial({
shininess: 5,
map: textureLoader.load("textures/planets/moon_1024.jpg"),
});
moon = new THREE.Mesh(moonGeometry, moonMaterial);
scene.add(moon);
创建CSS2D标签
这是关键部分,我们使用 CSS2DObject 来创建可以附加到 3D 对象上的 HTML 标签:
// 创建地球标签
const earthDiv = document.createElement('div');
earthDiv.className = "label";
earthDiv.innerHTML = "地球";
const earthLabel = new CSS2DObject(earthDiv);
earthLabel.position.set(0, 1, 0);
earth.add(earthLabel);
// 创建中国标签
const chinaDiv = document.createElement('div');
chinaDiv.className = "label1";
chinaDiv.innerHTML = "中国";
chinaLabel = new CSS2DObject(chinaDiv);
chinaLabel.position.set(-0.3, 0.5, -0.9);
earth.add(chinaLabel);
// 创建月球标签
const moonDiv = document.createElement('div');
moonDiv.className = "label";
moonDiv.innerHTML = "月球";
const moonLabel = new CSS2DObject(moonDiv);
moonLabel.position.set(0, 0.3, 0);
moon.add(moonLabel);
CSS2D渲染器设置
实例化并配置 CSS2D 渲染器:
// 实例化css2d的渲染器
labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(labelRenderer.domElement);
labelRenderer.domElement.style.position = 'fixed';
labelRenderer.domElement.style.top = '0px';
labelRenderer.domElement.style.left = '0px';
labelRenderer.domElement.style.zIndex = '10';
WebGL渲染器设置
设置标准的 WebGL 渲染器:
renderer = new THREE.WebGLRenderer();
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
控制器设置
配置轨道控制器以便用户可以交互:
const controls = new OrbitControls(camera, labelRenderer.domElement);
controls.minDistance = 5;
controls.maxDistance = 100;
窗口大小调整
处理窗口大小变化:
window.addEventListener("resize", onWindowResize);
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
labelRenderer.setSize(window.innerWidth, window.innerHeight);
}
动画循环与标签隐藏检测
在动画循环中,我们不仅移动月球,还实现了标签的智能显示/隐藏逻辑:
function animate() {
requestAnimationFrame(animate);
const elapsed = clock.getElapsedTime();
// 移动月球
moon.position.set(Math.sin(elapsed) * 5, 0, Math.cos(elapsed) * 5);
// 检测中国标签是否被遮挡
const chinaPosition = chinaLabel.position.clone();
// 计算出标签跟摄像机的距离
const labelDistance = chinaPosition.distanceTo(camera.position);
// 检测射线的碰撞
// 向量(坐标)从世界空间投影到相机的标准化设备坐标 (NDC) 空间。
chinaPosition.project(camera);
raycaster.setFromCamera(chinaPosition, camera);
const intersects = raycaster.intersectObjects(scene.children, true);
// 如果没有碰撞到任何物体,那么让标签显示
if(intersects.length == 0){
chinaLabel.element.classList.add('visible');
} else {
const minDistance = intersects[0].distance;
if(minDistance < labelDistance){
chinaLabel.element.classList.remove('visible');
} else {
chinaLabel.element.classList.add('visible');
}
}
// 标签渲染器渲染
labelRenderer.render(scene, camera);
// WebGL渲染器渲染
renderer.render(scene, camera);
}
CSS样式
为了让标签正确显示,我们需要适当的 CSS 样式:
.label {
color: #fff;
font-size: 1rem;
}
.label1 {
color: #fff;
display: none;
font-size: 1rem;
}
.label1.visible {
display: block;
}
CSS2DRenderer 工作原理
CSS2DRenderer 的工作原理是:
- 它不会渲染 3D 几何体,而是将附加到 3D 对象上的 HTML 元素定位到相应的位置
- 它会根据相机视角自动调整 HTML 元素的位置和大小
- HTML 元素始终保持面向相机,提供良好的可读性
标签遮挡检测
在这个示例中,我们实现了智能的标签遮挡检测:
- 使用 Raycaster 计算从相机到标签的射线
- 检查射线上是否有其他物体会遮挡标签
- 如果有遮挡,则隐藏标签;如果没有遮挡,则显示标签
优势与应用场景
CSS2DRenderer 的优势包括:
- 可以使用完整的 HTML 和 CSS 功能
- 支持复杂的布局和交互
- 文字渲染质量高
- 易于集成现有的 UI 组件
典型应用场景包括:
- 3D 场景中的标注和信息展示
- 地图应用中的地点标签
- 3D 模型的部件说明
- 数据可视化中的标签显示
总结
通过这个项目,我们学习了如何使用 Three.js 的 CSS2DRenderer:
- 如何创建和配置 CSS2DRenderer
- 如何使用 CSS2DObject 将 HTML 元素附加到 3D 对象
- 如何处理两个渲染器的协调工作
- 如何实现智能的标签遮挡检测
CSS2DRenderer 是一个强大的工具,它可以让我们在 3D 场景中轻松添加富文本标签和其他 HTML 元素,极大地增强了 3D 应用的信息展示能力。
Three.js 后期处理效果合成详解
概述
本文将详细介绍如何使用 Three.js 的后期处理系统来创建各种视觉效果。后期处理是在场景渲染完成后,对最终图像进行额外处理的技术,可以用来实现发光、模糊、故障效果等多种视觉增强效果。
准备工作
首先,我们需要引入必要的 Three.js 库和后期处理模块:
import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls";
import gsap from "gsap";
import * as dat from "dat.gui";
import { RGBELoader } from "three/examples/jsm/loaders/RGBELoader.js";
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader";
// 导入后期效果合成器
import {EffectComposer} from 'three/examples/jsm/postprocessing/EffectComposer';
// three框架本身自带效果
import {RenderPass} from 'three/examples/jsm/postprocessing/RenderPass';
import {DotScreenPass} from 'three/examples/jsm/postprocessing/DotScreenPass';
import {SMAAPass} from 'three/examples/jsm/postprocessing/SMAAPass';
import {SSAARenderPass} from 'three/examples/jsm/postprocessing/SSAARenderPass';
import {GlitchPass} from 'three/examples/jsm/postprocessing/GlitchPass';
import {UnrealBloomPass} from 'three/examples/jsm/postprocessing/UnrealBloomPass';
import {ShaderPass} from 'three/examples/jsm/postprocessing/ShaderPass';
场景初始化
首先,我们需要创建一个基本的 Three.js 场景:
// 初始化场景
const scene = new THREE.Scene();
// 创建透视相机
const camera = new THREE.PerspectiveCamera(
75,
window.innerHeight / window.innerHeight,
1,
50
);
// 设置相机位置
camera.position.set(0, 0, 3);
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
scene.add(camera);
// 加入辅助轴,帮助我们查看3维坐标轴
// const axesHelper = new THREE.AxesHelper(5);
// scene.add(axesHelper);
环境设置
设置环境纹理和光照:
// 加载纹理
const textureLoader = new THREE.TextureLoader();
// 添加环境纹理
const cubeTextureLoader = new THREE.CubeTextureLoader();
const envMapTexture = cubeTextureLoader.load([
"textures/environmentMaps/0/px.jpg",
"textures/environmentMaps/0/nx.jpg",
"textures/environmentMaps/0/py.jpg",
"textures/environmentMaps/0/ny.jpg",
"textures/environmentMaps/0/pz.jpg",
"textures/environmentMaps/0/nz.jpg",
]);
scene.background = envMapTexture;
scene.environment = envMapTexture;
// 添加方向光
const directionLight = new THREE.DirectionalLight('#ffffff', 1);
directionLight.castShadow = true;
directionLight.position.set(0, 0, 200);
scene.add(directionLight);
模型加载
加载 3D 模型:
// 模型加载
const gltfLoader = new GLTFLoader();
gltfLoader.load('./models/DamagedHelmet/glTF/DamagedHelmet.gltf', (gltf) => {
console.log(gltf);
const mesh = gltf.scene.children[0];
scene.add(mesh);
});
后期处理合成器设置
这是后期处理的核心部分,创建效果合成器并添加各种通道:
// 初始化渲染器
const renderer = new THREE.WebGLRenderer();
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.shadowMap.enabled = true;
// 合成效果
const effectComposer = new EffectComposer(renderer);
effectComposer.setSize(window.innerWidth, window.innerHeight);
// 添加渲染通道
const renderPass = new RenderPass(scene, camera);
effectComposer.addPass(renderPass);
// 点效果
const dotScreenPass = new DotScreenPass();
dotScreenPass.enabled = false;
effectComposer.addPass(dotScreenPass);
// 抗锯齿
const smaaPass = new SMAAPass();
effectComposer.addPass(smaaPass);
// 发光效果
const unrealBloomPass = new UnrealBloomPass();
effectComposer.addPass(unrealBloomPass);
// 屏幕闪动
// const glitchPass = new GlitchPass();
// effectComposer.addPass(glitchPass)
发光效果参数调节
设置发光效果的参数并添加 GUI 控制:
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1;
unrealBloomPass.strength = 1;
unrealBloomPass.radius = 0;
unrealBloomPass.threshold = 1;
// 添加GUI控制
gui.add(renderer,'toneMappingExposure').min(0).max(2).step(0.01);
gui.add(unrealBloomPass,'strength').min(0).max(2).step(0.01);
gui.add(unrealBloomPass,'radius').min(0).max(2).step(0.01);
gui.add(unrealBloomPass,'threshold').min(0).max(2).step(0.01);
自定义着色器后期处理
创建自定义着色器效果:
// 着色器写渲染通道
const shaderPass = new ShaderPass(
{
uniforms:{
tDiffuse:{
value:null
},
uColor:{
value:new THREE.Color(colorParams.r,colorParams.g,colorParams.b)
}
},
vertexShader:`
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);
}
`,
fragmentShader:`
varying vec2 vUv;
uniform sampler2D tDiffuse;
uniform vec3 uColor;
void main(){
vec4 color = texture2D(tDiffuse,vUv);
// gl_FragColor = vec4(vUv,0.0,1.0);
color.xyz+=uColor;
gl_FragColor = color;
}
`
}
);
effectComposer.addPass(shaderPass);
// 颜色参数控制
const colorParams = {
r:0,
g:0,
b:0
}
gui.add(colorParams,'r').min(-1).max(1).step(0.01).onChange((value)=>{
shaderPass.uniforms.uColor.value.r = value;
});
gui.add(colorParams,'g').min(-1).max(1).step(0.01).onChange((value)=>{
shaderPass.uniforms.uColor.value.g = value;
});
gui.add(colorParams,'b').min(-1).max(1).step(0.01).onChange((value)=>{
shaderPass.uniforms.uColor.value.b = value;
});
技术效果着色器
添加技术感的后期处理效果:
const normalTexture = textureLoader.load('./textures/interfaceNormalMap.png');
const techPass = new ShaderPass({
uniforms:{
tDiffuse:{
value:null
},
uNormalMap:{
value:null
},
uTime:{
value:0
}
},
vertexShader:`
varying vec2 vUv;
void main(){
vUv = uv;
gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0);
}
`,
fragmentShader:`
varying vec2 vUv;
uniform sampler2D tDiffuse;
uniform sampler2D uNormalMap;
uniform float uTime;
void main(){
vec2 newUv = vUv;
newUv += sin(newUv.x*10.0+uTime*0.5)*0.03;
vec4 color = texture2D(tDiffuse,newUv);
// gl_FragColor = vec4(vUv,0.0,1.0);
vec4 normalColor = texture2D(uNormalMap,vUv);
// 设置光线的角度
vec3 lightDirection = normalize(vec3(-5,5,2)) ;
float lightness = clamp(dot(normalColor.xyz,lightDirection),0.0,1.0) ;
color.xyz+=lightness;
gl_FragColor = color;
}
`
})
techPass.material.uniforms.uNormalMap.value = normalTexture;
effectComposer.addPass(techPass);
渲染循环
设置渲染循环并应用后期处理:
const clock = new THREE.Clock();
function animate(t) {
controls.update();
const time = clock.getElapsedTime();
requestAnimationFrame(animate);
// 使用渲染器渲染相机看这个场景的内容渲染出来
// renderer.render(scene, camera);
techPass.material.uniforms.uTime.value = time;
effectComposer.render();
}
animate();
窗口大小调整
处理窗口大小变化:
// 监听屏幕大小改变的变化,设置渲染的尺寸
window.addEventListener("resize", () => {
// 更新摄像头
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像机的投影矩阵
camera.updateProjectionMatrix();
// 更新渲染器
renderer.setSize(window.innerWidth, window.innerHeight);
// 设置渲染器的像素比例
renderer.setPixelRatio(window.devicePixelRatio);
effectComposer.setSize(window.innerWidth, window.innerHeight);
effectComposer.setPixelRatio(window.devicePixelRatio);
});
各种后期处理效果介绍
- RenderPass: 基础渲染通道,负责渲染原始场景
- DotScreenPass: 点阵屏幕效果,产生类似扫描线的视觉效果
- SMAAPass: 智能抗锯齿处理,提高画面质量
- UnrealBloomPass: 虚幻引擎风格的发光效果,使明亮区域产生光晕
- GlitchPass: 故障效果,模拟信号干扰的视觉效果
- ShaderPass: 自定义着色器效果,可实现任意的后期处理效果
总结
通过这个项目,我们学习了如何使用 Three.js 的后期处理系统:
- 创建 EffectComposer 作为后期处理的核心
- 添加不同类型的 Pass 来实现各种效果
- 通过参数调节控制效果强度
- 实现自定义着色器后期处理
- 在渲染循环中使用 composer.render() 替代传统的 renderer.render()
后期处理技术是提升三维场景视觉效果的重要手段,能够显著增强画面的表现力和沉浸感。掌握这些技术可以让你的 Three.js 应用更具视觉冲击力。
Three.js 收藏吃灰系列:一套代码,把你的 GLB 模型变成漫威级全息投影
Three.js 进阶实战:打造“锐利边缘”的赛博朋克特效
在 Three.js 的 3D 开发中,我们经常需要制作“扫描”、“全息”或者“能量护盾”的效果。最常用的手段是使用 菲涅尔(Fresnel) 效应。
但是,默认的菲涅尔效果往往呈现出一种“软绵绵”的雾化感,中心区域总是有一层洗不掉的朦胧。如果你想要一种硬核、锐利、仅保留物体轮廓的赛博朋克风格(Hard-surface Edge),就需要对 Shader 进行一些特殊的数学运算截断。
今天,我们就来拆解一套**“锐利边缘能量体”**的代码实现。
前排提示:本文涉及的特效逻辑,源自我正在开发的 3D 编辑器项目 Meteor3DEditor。如果你对 Web3D 引擎开发感兴趣,欢迎 Star 和体验!
- GitHub: github.com/nikonikoCW/…
- 官网: meteor3d.cn/
核心难点:如何让菲涅尔变得“锐利”?
普通的菲涅尔公式通常是 1.0 - dot(viewDir, normal)。这会产生一个从边缘向中心平滑过渡的渐变。
为了得到锐利的边缘,我们需要在 Fragment Shader 中做三件事:
- 极高次幂压缩:把边缘压扁。
- 阈值截断:把中心的“雾气”彻底切除。
- 亮度重映射:把剩下的边缘提亮。
1. Shader 代码解析
这是核心的 ShaderMaterial 实现。请注意看 fragmentShader 中的注释部分:
JavaScript
const sharpFresnelShader = {
uniforms: {
color: { value: new THREE.Color(0x00ffff) }, // 赛博青色
opacityMultiplier: { value: 1.0 }
},
vertexShader: `
varying vec3 vWorldNormal;
varying vec3 vViewDirection;
void main() {
// 获取世界坐标系下的法线
vWorldNormal = normalize(mat3(modelMatrix) * normal);
vec4 worldPosition = modelMatrix * vec4(position, 1.0);
// 计算视线方向
vViewDirection = normalize(cameraPosition - worldPosition.xyz);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
uniform vec3 color;
uniform float opacityMultiplier;
varying vec3 vWorldNormal;
varying vec3 vViewDirection;
void main() {
vec3 normal = normalize(vWorldNormal);
vec3 viewDir = normalize(vViewDirection);
// --- 基础菲涅尔 ---
float dotProduct = dot(viewDir, normal);
// 1.0 - abs(dot) 确保双面渲染时背面也能正确发光
float fresnel = 1.0 - abs(dotProduct);
fresnel = clamp(fresnel, 0.0, 1.0);
// --- 关键魔法区域 ---
// 技巧1:使用 pow(6.0) 甚至更高。
// 默认的菲涅尔可能是 pow(2.0),看起来很软。
// 6.0 的指数会让函数曲线极速陡峭,只有最边缘的地方保留数值,其余迅速归零。
fresnel = pow(fresnel, 6.0);
// 技巧2:阈值截断 (Threshold)
// 即使是 pow(6.0),中心正对相机的地方可能还有 0.01 的微弱值。
// 这种微弱值在叠加模式下会导致模型看起来脏脏的。
// 我们减去 0.1,强制把这一部分变成纯黑(透明)。
fresnel = max(fresnel - 0.1, 0.0);
// 技巧3:亮度补偿
// 因为刚才减去了 0.1,最大值只剩 0.9 了,而且整体变暗。
// 乘一个系数把边缘亮度拉爆。
fresnel *= 2.5;
// ------------------
float finalAlpha = fresnel * opacityMultiplier;
// 颜色也乘以 alpha,配合 AdditiveBlending 食用效果更佳
gl_FragColor = vec4(color * finalAlpha, finalAlpha);
}
`
};
2. 材质配置的关键点
Shader 写好了,Material 的配置同样重要。为了达到“全息投影”那种通透感,我们需要关闭深度写入,并开启叠加混合。
JavaScript
this.energyMaterial = new THREE.ShaderMaterial({
uniforms: THREE.UniformsUtils.clone(sharpFresnelShader.uniforms),
vertexShader: sharpFresnelShader.vertexShader,
fragmentShader: sharpFresnelShader.fragmentShader,
transparent: true,
side: THREE.DoubleSide, // 双面渲染:让模型内部的结构线条也能透出来,增加复杂度
depthWrite: false, // 关闭深度写入:这是实现“透明重叠”不穿帮的关键
blending: THREE.AdditiveBlending // 叠加混合:重叠的部分会变亮(变白),模拟光的物理叠加
});
动态系统:上升的能量残影
光有一个静止的边缘是不够帅的。我们需要让它动起来。这里我们实现了一个简单的粒子系统思想:不断克隆模型,向上发射,然后消失。
系统逻辑 (RisingEnergySystem)
这个类负责管理所有的“残影(Ghost)”。
-
Spawn (生成) :每隔几百毫秒,克隆一次目标模型。
-
Update (更新) :
- 将所有残影沿 Y 轴向上移动。
- 随着时间流逝,减小
opacityMultiplier(透明度)。 - 当生命周期结束,从场景移除并销毁内存。
JavaScript
spawn(originPosition, originRotation, scale) {
if (this.sourceGeoGroups.length === 0) return;
const ghostGroup = new THREE.Group();
// 技巧:每次生成时微调颜色,让能量场色彩更丰富
const instanceMat = this.energyMaterial.clone();
instanceMat.uniforms.color.value.setHSL(0.5 + Math.random() * 0.1, 1.0, 0.5);
this.sourceGeoGroups.forEach(template => {
const mesh = template.clone();
mesh.material = instanceMat;
ghostGroup.add(mesh);
});
// ...设置位置、旋转、缩放...
// 稍微向上偏移一点出生点,避免和实体模型完全重叠导致 Z-Fighting 闪烁
ghostGroup.position.y += 0.05;
this.scene.add(ghostGroup);
this.ghosts.push({
mesh: ghostGroup,
materialBtn: instanceMat,
life: 1.0,
speed: 1.5
});
}
为什么会有“扫描”的感觉?
因为我们使用了 AdditiveBlending(叠加混合)。
当新的残影生成时,它离实体模型很近。实体模型本身可能有一个基础亮度,加上残影的亮度,重叠部分会爆亮。随着残影上升,它与本体分离,光线被“拉”开了。这种高亮 -> 分离 -> 消失的过程,视觉上就形成了强烈的能量扫描感。
![效果图占位:建议放一张局部特写,展示边缘重叠变亮的效果]
性能优化小贴士
在代码中,有一个细节处理:
JavaScript
// update 循环中
ghost.materialBtn.dispose(); // 清理内存
由于我们不断地 new ShaderMaterial(或者 clone),这会创建大量的 WebGL Program。如果不手动 dispose,Three.js 可能会保留这些材质引用,导致内存泄漏。虽然在 Demo 级别无所谓,但在生产环境中,建议使用材质池(Material Pool)或者InstancedMesh来复用资源,避免频繁创建销毁。
完整体验与源码
这个特效非常适合用于展示高科技感的 3D 模型,比如无人机、芯片、或者飞船内部结构。
如果你想看完整的运行效果,或者想直接 Copy 源码运行,我已经整理在文章开头了。而如果你想在一个可视化的环境中直接调整这些 Shader 参数,而不用每次都改代码,欢迎尝试我开发的 Web3D 编辑器:
🌌 Meteor3DEditor
Meteor3D 是一个基于 Web 的轻量级 3D 编辑器,旨在简化 Three.js 的场景搭建和特效调试。
- GitHub: github.com/nikonikoCW/…
- 在线官网: meteor3d.cn/
如果你觉得这个特效对你有帮助,欢迎来 GitHub 点个 Star ⭐️,也欢迎提 Issue 交流更多的 Shader 技巧!
总结:
- Pow(6.0) 制造锐利边缘。
- Minus Offset 去除中心雾化。
- AdditiveBlending 制造光的叠加感。
- DoubleSide 增加模型内部细节的透视感。
希望这篇文章能给你的 WebGL 开发带来一点灵感!
学习Three.js--轨道+火车自动行进
学习Three.js--轨道+火车自动行进
前置核心说明
开发目标
基于Three.js实现带真实纹理的火车轨道 + 沿轨道自动循环行进的简易火车,核心能力包括:
- 轨道模块化构建(钢轨分段创建+枕木间隔排布,支持参数化配置);
- 简易火车模型搭建(车身+4个车轮,车轮精准贴合钢轨);
- 火车沿Z轴自动行进,到达轨道末端后循环重置;
- 自然光照系统+纹理贴图(金属钢轨、木质枕木);
- 基础交互(拖拽旋转视角、滚轮缩放)。
核心技术栈(关键知识点)
| 技术点 | 作用 |
|---|---|
THREE.Group |
分组管理轨道/火车的子组件(钢轨/枕木/车身/车轮),方便整体控制 |
BoxGeometry/CylinderGeometry
|
分别创建轨道(立方体)、车轮(圆柱体)的基础几何形状 |
MeshStandardMaterial |
PBR物理材质,支持纹理、金属度、粗糙度,模拟真实钢轨/车轮质感 |
纹理重复配置(wrapS/wrapT/repeat) |
避免纹理拉伸,适配轨道分段尺寸,提升视觉真实度 |
动画循环(requestAnimationFrame) |
实时更新火车位置,实现自动行进+循环逻辑 |
OrbitControls |
基础视角交互(旋转/缩放/阻尼) |
| 几何体旋转/定位 | 车轮旋转适配钢轨方向、轨道/火车精准贴地/居中 |
核心开发流程
A[初始化场景/相机/渲染器/控制器] --> B[配置轨道+火车参数(可复用)]
B --> C[加载纹理并配置重复规则]
C --> D[创建轨道(钢轨分段+枕木间隔)]
D --> E[创建火车模型(车身+车轮精准定位)]
E --> F[创建地面+添加所有元素到场景]
F --> G[窗口适配+动画循环(火车行进+循环)]
分步开发详解
步骤1:基础环境搭建(场景/相机/渲染器/控制器/光照)
1.1 核心代码
// 1. 场景初始化(所有元素的容器)
const scene = new THREE.Scene();
// 2. 透视相机(模拟人眼视角,适合3D场景)
const camera = new THREE.PerspectiveCamera(
60, // 视角(FOV)
window.innerWidth / window.innerHeight, // 宽高比
1, // 近裁切面
3000 // 远裁切面
);
camera.position.set(5, 3, 10); // 调整视角,俯视轨道
// 3. 渲染器(抗锯齿+高清适配)
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
// 4. 轨道控制器(交互:旋转/缩放/阻尼)
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true; // 阻尼(惯性),交互更顺滑
controls.dampingFactor = 0.05; // 阻尼系数
// 5. 光照系统(模拟自然光照)
// 环境光:补充暗部,避免纯黑
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
// 方向光:模拟太阳光,提升立体感
const dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
dirLight.position.set(200, 400, 300); // 高位斜向光照
dirLight.castShadow = false; // 简化场景,禁用阴影
scene.add(dirLight);
1.2 关键说明
-
相机视角:
position.set(5, 3, 10)采用俯视视角,既能看到轨道延伸,又能清晰观察火车行进; -
光照组合:
AmbientLight+DirectionalLight是Three.js基础光照方案,兼顾暗部细节和主体立体感; - 控制器阻尼:启用阻尼后,视角拖拽/缩放有惯性,交互体验更自然。
步骤2:核心参数配置
2.1 核心代码
// 轨道参数配置(集中管理,方便修改)
const TRACK_PARAMS = {
railWidth: 0.2, // 钢轨宽度
railHeight: 0.1, // 钢轨高度
railSpacing: 1.0, // 两条钢轨中心距
sleeperWidth: 0.1, // 枕木宽度(沿Z轴)
sleeperHeight: 0.05, // 枕木高度
sleeperLength: 1.5, // 枕木长度(超过钢轨间距,覆盖两侧)
sleeperGap: 1.0, // 枕木间距
trackLength: 50, // 轨道总长度
segmentLength: 1.0 // 钢轨分段长度(避免单个几何体过长)
};
// 火车参数配置(集中管理)
const TRAIN_PARAMS = {
speed: 0.01, // 火车行进速度(每帧移动距离)
wheelRadius: 0.1, // 车轮半径(刚好贴合钢轨高度)
wheelWidth: 0.1, // 车轮宽度
bodyLength: 2.0, // 车身长度
bodyWidth: 0.8, // 车身宽度(小于钢轨间距,居中)
bodyHeight: 0.5 // 车身高度
};
2.2 关键设计思路
- 参数集中化:将轨道/火车的尺寸、间距、速度等参数集中定义,后续修改无需遍历代码,符合「高内聚低耦合」原则;
-
尺寸匹配:
wheelRadius = railHeight保证车轮刚好贴合钢轨上表面,bodyWidth < railSpacing保证车身居中在两条钢轨之间。
步骤3:纹理加载与配置(避免拉伸,提升真实度)
3.1 核心代码
const texLoader = new THREE.TextureLoader();
// 钢轨纹理(生锈金属,适配轨道质感)
const railTexture = texLoader.load('./rusty_metal_05_diff_1k.jpg', () => {
renderer.render(scene, camera); // 纹理加载完成后重绘
});
// 纹理关键配置:避免拉伸
railTexture.colorSpace = THREE.SRGBColorSpace; // 正确的颜色空间
railTexture.wrapS = THREE.RepeatWrapping; // 水平(X轴)重复
railTexture.wrapT = THREE.RepeatWrapping; // 垂直(Y轴)重复
railTexture.repeat.set(2, 10); // 重复次数(适配钢轨尺寸)
railTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); // 提升斜向清晰度
// 枕木纹理(木质纹理)
const sleeperTexture = texLoader.load('./bark_willow_02_diff_1k.jpg');
sleeperTexture.colorSpace = THREE.SRGBColorSpace;
sleeperTexture.wrapS = THREE.RepeatWrapping;
sleeperTexture.wrapT = THREE.RepeatWrapping;
sleeperTexture.repeat.set(1, 1); // 单个枕木单次纹理
3.2 核心技术点
-
纹理重复(RepeatWrapping):默认纹理是
ClampToEdgeWrapping(拉伸),设置为RepeatWrapping后,纹理会按repeat值重复排列,避免长钢轨纹理拉伸模糊; - 各向异性(anisotropy):提升纹理在斜向视角下的清晰度,尤其适合轨道这种长条状物体;
-
颜色空间:
SRGBColorSpace是纹理的标准颜色空间,保证纹理颜色显示正确。
步骤4:轨道创建(核心逻辑:分段钢轨+间隔枕木)
4.1 核心代码
function createTrainTrack() {
const trackGroup = new THREE.Group(); // 轨道组,统一管理钢轨/枕木
// 1. 创建钢轨(两条平行,分段构建)
const railMaterial = new THREE.MeshStandardMaterial({
map: railTexture,
metalness: 0.8, // 高金属度,模拟钢轨质感
roughness: 0.2 // 低粗糙度,有反光
});
// 左侧钢轨(分段创建)
for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
const railGeometry = new THREE.BoxGeometry(
TRACK_PARAMS.railWidth,
TRACK_PARAMS.railHeight,
TRACK_PARAMS.segmentLength
);
const rail = new THREE.Mesh(railGeometry, railMaterial);
// 定位:左侧钢轨(X负方向),底部贴地
rail.position.set(
-TRACK_PARAMS.railSpacing / 2, // 左侧钢轨X坐标
TRACK_PARAMS.railHeight / 2, // Y轴:底部贴地(高度/2)
z + TRACK_PARAMS.segmentLength / 2 // Z轴:分段居中
);
trackGroup.add(rail);
}
// 右侧钢轨(分段创建,逻辑同左侧)
for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
const railGeometry = new THREE.BoxGeometry(
TRACK_PARAMS.railWidth,
TRACK_PARAMS.railHeight,
TRACK_PARAMS.segmentLength
);
const rail = new THREE.Mesh(railGeometry, railMaterial);
rail.position.set(
TRACK_PARAMS.railSpacing / 2, // 右侧钢轨X坐标
TRACK_PARAMS.railHeight / 2,
z + TRACK_PARAMS.segmentLength / 2
);
trackGroup.add(rail);
}
// 2. 创建枕木(横向间隔排布)
const sleeperMaterial = new THREE.MeshStandardMaterial({ map: sleeperTexture });
for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.sleeperGap) {
const sleeperGeometry = new THREE.BoxGeometry(
TRACK_PARAMS.sleeperLength, // 长度(X轴)覆盖两条钢轨
TRACK_PARAMS.sleeperHeight,
TRACK_PARAMS.sleeperWidth
);
const sleeper = new THREE.Mesh(sleeperGeometry, sleeperMaterial);
// 定位:居中,底部贴地,沿Z轴间隔排布
sleeper.position.set(
0, // X轴居中
TRACK_PARAMS.sleeperHeight / 2, // Y轴贴地
z // Z轴间隔排布
);
trackGroup.add(sleeper);
}
return trackGroup;
}
4.2 核心技术点解析
-
钢轨分段创建:若直接创建长度为50的钢轨几何体,顶点数过多且纹理拉伸严重;分段(
segmentLength=1.0)后,每个钢轨段尺寸小、纹理重复合理,性能更优; -
Group分组管理:将所有钢轨、枕木添加到
trackGroup,后续可通过操作trackGroup整体移动/旋转轨道,便于扩展; - 定位逻辑:所有几何体的Y轴位置为「高度/2」,保证底部贴地(Y=0),避免悬浮/埋地。
步骤5:火车模型创建(车身+车轮,精准贴合轨道)
5.1 核心代码
function createTrain() {
const trainGroup = new THREE.Group(); // 火车组,统一管理车身/车轮
// 1. 创建车身(立方体)
const bodyMaterial = new THREE.MeshStandardMaterial({
color: 0xff4444, // 红色车身
metalness: 0.1,
roughness: 0.8
});
const bodyGeometry = new THREE.BoxGeometry(
TRAIN_PARAMS.bodyWidth,
TRAIN_PARAMS.bodyHeight,
TRAIN_PARAMS.bodyLength
);
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
// 车身定位:居中在钢轨之间,Y轴=车轮半径+车身高度/2
body.position.set(
0, // X轴居中
TRAIN_PARAMS.wheelRadius + TRAIN_PARAMS.bodyHeight / 2, // Y轴高度
0 // Z轴初始位置
);
trainGroup.add(body);
// 2. 创建车轮(圆柱体,共4个:前左/前右/后左/后右)
const wheelMaterial = new THREE.MeshStandardMaterial({
color: 0x333333, // 黑色车轮
metalness: 0.8,
roughness: 0.2
});
const wheelGeometry = new THREE.CylinderGeometry(
TRAIN_PARAMS.wheelRadius, // 顶部半径
TRAIN_PARAMS.wheelRadius, // 底部半径
TRAIN_PARAMS.wheelWidth, // 圆柱体高度(车轮宽度)
16 // 分段数,越多越圆
);
// 前左车轮
const frontLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
frontLeftWheel.rotation.z = Math.PI / 2; // 旋转90°,横向贴合钢轨
frontLeftWheel.position.set(
-TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2, // 对齐左侧钢轨
TRAIN_PARAMS.wheelRadius, // Y轴=车轮半径(贴钢轨)
TRAIN_PARAMS.bodyLength / 2 - 0.2 // 车身前部位置
);
trainGroup.add(frontLeftWheel);
// 前右车轮(逻辑同前左,X轴反向)
const frontRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
frontRightWheel.rotation.z = Math.PI / 2;
frontRightWheel.position.set(
TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
TRAIN_PARAMS.wheelRadius,
TRAIN_PARAMS.bodyLength / 2 - 0.2
);
trainGroup.add(frontRightWheel);
// 后左车轮(Z轴反向)
const backLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
backLeftWheel.rotation.z = Math.PI / 2;
backLeftWheel.position.set(
-TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2,
TRAIN_PARAMS.wheelRadius,
-TRAIN_PARAMS.bodyLength / 2 + 0.2
);
trainGroup.add(backLeftWheel);
// 后右车轮(X/Z轴反向)
const backRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
backRightWheel.rotation.z = Math.PI / 2;
backRightWheel.position.set(
TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
TRAIN_PARAMS.wheelRadius,
-TRAIN_PARAMS.bodyLength / 2 + 0.2
);
trainGroup.add(backRightWheel);
// 初始化火车位置(轨道起点)
trainGroup.position.z = 0;
return trainGroup;
}
5.2 核心技术点解析
-
车轮旋转:
CylinderGeometry默认是垂直方向(Y轴),通过rotation.z = Math.PI / 2旋转为水平方向(X轴),贴合钢轨延伸方向; -
车轮定位:
-TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2精准对齐钢轨内侧,避免车轮偏移; -
车身高度:
wheelRadius + bodyHeight / 2保证车身底部与车轮顶部贴合,模拟真实火车结构。
步骤6:地面创建+元素组装
6.1 核心代码
// 创建地面(大平面,衬托轨道)
const groundGeometry = new THREE.PlaneGeometry(100, 100); // 足够大的地面
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2; // 旋转90°,从垂直变为水平(贴地)
ground.position.y = 0;
scene.add(ground);
// 创建轨道和火车,添加到场景
const track = createTrainTrack();
const train = createTrain();
scene.add(track);
scene.add(train);
6.2 关键说明
-
地面旋转:
PlaneGeometry默认是XY平面(垂直),rotation.x = -Math.PI / 2旋转为XZ平面(水平),作为场景地面; -
地面尺寸:
100x100远大于轨道长度(50),保证轨道完全覆盖在地面上。
步骤7:窗口适配+动画循环(火车行进核心)
7.1 核心代码
// 窗口适配(响应式)
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix(); // 必须更新投影矩阵
renderer.setSize(window.innerWidth, window.innerHeight);
});
// 渲染循环(火车行进+循环逻辑)
function animate() {
requestAnimationFrame(animate);
// 核心:火车沿Z轴行进
train.position.z += TRAIN_PARAMS.speed;
// 循环逻辑:到达轨道末端后重置到起点
if (train.position.z > TRACK_PARAMS.trackLength) {
train.position.z = -TRAIN_PARAMS.bodyLength; // 起点略超前,避免突兀
}
controls.update(); // 更新控制器(阻尼)
renderer.render(scene, camera); // 渲染场景
}
animate();
7.2 核心技术点解析
-
火车行进逻辑:通过每帧修改
train.position.z实现沿轨道(Z轴)行进,speed控制行进速度; -
循环重置:
train.position.z > TRACK_PARAMS.trackLength时,重置为-TRAIN_PARAMS.bodyLength(车身长度负值),保证火车从轨道起点外进入,循环更自然; -
窗口适配:相机宽高比修改后,必须调用
updateProjectionMatrix()使修改生效。
核心技术点深度解析
1. 钢轨分段的必要性
- 性能层面:单个长度为50的钢轨几何体,顶点数=(宽度分段×高度分段×长度分段),远多于50个长度为1的分段几何体总和;
-
纹理层面:分段后每个钢轨段的纹理可通过
repeat精准控制,避免长钢轨纹理拉伸模糊; - 扩展层面:分段轨道更容易实现弯曲轨道(后续可通过修改每个分段的旋转/位置实现曲线)。
2. 车轮与钢轨的精准贴合
| 参数匹配 | 效果 |
|---|---|
wheelRadius = railHeight |
车轮半径等于钢轨高度,车轮底部刚好落在钢轨上表面 |
车轮X坐标 = ±railSpacing/2 ± railWidth/2
|
车轮内侧对齐钢轨外侧,避免偏移 |
车轮Y坐标 = wheelRadius
|
车轮中心Y轴高度=半径,底部贴钢轨上表面 |
3. 动画循环的核心逻辑
A[每帧执行animate] --> B[train.position.z += speed]
B --> C{z > trackLength?}
C -- 是 --> D[z = -bodyLength(重置)]
C -- 否 --> E[继续行进]
D --> F[渲染场景]
E --> F[渲染场景]
完整优化代码
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>Three.js 轨道+火车自动行进</title>
<style>
body { margin: 0; overflow: hidden; }
#info {
position: absolute;
top: 10px;
width: 100%;
text-align: center;
color: white;
font-family: Arial, sans-serif;
text-shadow: 0 0 5px rgba(0,0,0,0.8);
pointer-events: none;
z-index: 10;
}
</style>
</head>
<body>
<div id="info">火车沿轨道自动行进 | 拖拽旋转视角 | 滚轮缩放</div>
<script type="module">
import * as THREE from 'https://esm.sh/three@0.174.0';
import { OrbitControls } from 'https://esm.sh/three@0.174.0/examples/jsm/controls/OrbitControls.js';
// ========== 1. 基础环境初始化 ==========
// 场景:所有元素容器
const scene = new THREE.Scene();
// 透视相机:俯视视角观察轨道+火车
const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 1, 3000);
camera.position.set(5, 3, 10); // 俯视视角参数
// 渲染器:抗锯齿+高清适配
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.setPixelRatio(window.devicePixelRatio);
document.body.appendChild(renderer.domElement);
// 轨道控制器:启用阻尼,交互更顺滑
const controls = new OrbitControls(camera, renderer.domElement);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
// 光照系统:环境光+方向光,兼顾暗部和立体感
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const dirLight = new THREE.DirectionalLight(0xffffff, 0.9);
dirLight.position.set(200, 400, 300);
dirLight.castShadow = false; // 简化场景,禁用阴影
scene.add(dirLight);
// ========== 2. 核心参数配置(集中管理,方便修改) ==========
// 轨道参数
const TRACK_PARAMS = {
railWidth: 0.2, // 钢轨宽度
railHeight: 0.1, // 钢轨高度
railSpacing: 1.0, // 两条钢轨中心距
sleeperWidth: 0.1, // 枕木宽度(沿Z轴)
sleeperHeight: 0.05, // 枕木高度
sleeperLength: 1.5, // 枕木长度(覆盖钢轨两侧)
sleeperGap: 1.0, // 枕木间距
trackLength: 50, // 轨道总长度
segmentLength: 1.0 // 钢轨分段长度(避免单个几何体过长)
};
// 火车参数
const TRAIN_PARAMS = {
speed: 0.01, // 火车行进速度(每帧移动距离)
wheelRadius: 0.1, // 车轮半径(贴合钢轨高度)
wheelWidth: 0.1, // 车轮宽度
bodyLength: 2.0, // 车身长度
bodyWidth: 0.8, // 车身宽度(小于钢轨间距,居中)
bodyHeight: 0.5 // 车身高度
};
// ========== 3. 纹理加载与配置(避免拉伸,提升真实度) ==========
const texLoader = new THREE.TextureLoader();
// 钢轨纹理:生锈金属,配置重复规则
const railTexture = texLoader.load('./rusty_metal_05_diff_1k.jpg', () => {
renderer.render(scene, camera); // 纹理加载完成后重绘
});
railTexture.colorSpace = THREE.SRGBColorSpace;
railTexture.wrapS = THREE.RepeatWrapping;
railTexture.wrapT = THREE.RepeatWrapping;
railTexture.repeat.set(2, 10); // 纹理重复次数
railTexture.anisotropy = renderer.capabilities.getMaxAnisotropy(); // 提升斜向清晰度
// 枕木纹理:木质纹理
const sleeperTexture = texLoader.load('./bark_willow_02_diff_1k.jpg');
sleeperTexture.colorSpace = THREE.SRGBColorSpace;
sleeperTexture.wrapS = THREE.RepeatWrapping;
sleeperTexture.wrapT = THREE.RepeatWrapping;
sleeperTexture.repeat.set(1, 1);
// ========== 4. 轨道创建(分段钢轨+间隔枕木) ==========
function createTrainTrack() {
const trackGroup = new THREE.Group(); // 轨道组,统一管理
// 钢轨材质:高金属度,模拟钢轨质感
const railMaterial = new THREE.MeshStandardMaterial({
map: railTexture,
metalness: 0.8,
roughness: 0.2
});
// 左侧钢轨:分段创建
for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
const railGeometry = new THREE.BoxGeometry(
TRACK_PARAMS.railWidth,
TRACK_PARAMS.railHeight,
TRACK_PARAMS.segmentLength
);
const rail = new THREE.Mesh(railGeometry, railMaterial);
// 定位:左侧+贴地+分段居中
rail.position.set(
-TRACK_PARAMS.railSpacing / 2,
TRACK_PARAMS.railHeight / 2,
z + TRACK_PARAMS.segmentLength / 2
);
trackGroup.add(rail);
}
// 右侧钢轨:分段创建
for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.segmentLength) {
const railGeometry = new THREE.BoxGeometry(
TRACK_PARAMS.railWidth,
TRACK_PARAMS.railHeight,
TRACK_PARAMS.segmentLength
);
const rail = new THREE.Mesh(railGeometry, railMaterial);
// 定位:右侧+贴地+分段居中
rail.position.set(
TRACK_PARAMS.railSpacing / 2,
TRACK_PARAMS.railHeight / 2,
z + TRACK_PARAMS.segmentLength / 2
);
trackGroup.add(rail);
}
// 枕木材质:木质纹理
const sleeperMaterial = new THREE.MeshStandardMaterial({ map: sleeperTexture });
// 枕木:间隔创建
for (let z = 0; z < TRACK_PARAMS.trackLength; z += TRACK_PARAMS.sleeperGap) {
const sleeperGeometry = new THREE.BoxGeometry(
TRACK_PARAMS.sleeperLength,
TRACK_PARAMS.sleeperHeight,
TRACK_PARAMS.sleeperWidth
);
const sleeper = new THREE.Mesh(sleeperGeometry, sleeperMaterial);
// 定位:居中+贴地+间隔排布
sleeper.position.set(
0,
TRACK_PARAMS.sleeperHeight / 2,
z
);
trackGroup.add(sleeper);
}
return trackGroup;
}
// ========== 5. 火车创建(车身+车轮,精准贴合轨道) ==========
function createTrain() {
const trainGroup = new THREE.Group(); // 火车组,统一管理
// 车身材质:红色,低金属度
const bodyMaterial = new THREE.MeshStandardMaterial({
color: 0xff4444,
metalness: 0.1,
roughness: 0.8
});
// 车身几何体
const bodyGeometry = new THREE.BoxGeometry(
TRAIN_PARAMS.bodyWidth,
TRAIN_PARAMS.bodyHeight,
TRAIN_PARAMS.bodyLength
);
const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
// 车身定位:居中+贴合车轮顶部
body.position.set(
0,
TRAIN_PARAMS.wheelRadius + TRAIN_PARAMS.bodyHeight / 2,
0
);
trainGroup.add(body);
// 车轮材质:黑色,高金属度
const wheelMaterial = new THREE.MeshStandardMaterial({
color: 0x333333,
metalness: 0.8,
roughness: 0.2
});
// 车轮几何体:圆柱体
const wheelGeometry = new THREE.CylinderGeometry(
TRAIN_PARAMS.wheelRadius,
TRAIN_PARAMS.wheelRadius,
TRAIN_PARAMS.wheelWidth,
16
);
// 前左车轮
const frontLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
frontLeftWheel.rotation.z = Math.PI / 2; // 旋转为横向
frontLeftWheel.position.set(
-TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2,
TRAIN_PARAMS.wheelRadius,
TRAIN_PARAMS.bodyLength / 2 - 0.2
);
trainGroup.add(frontLeftWheel);
// 前右车轮
const frontRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
frontRightWheel.rotation.z = Math.PI / 2;
frontRightWheel.position.set(
TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
TRAIN_PARAMS.wheelRadius,
TRAIN_PARAMS.bodyLength / 2 - 0.2
);
trainGroup.add(frontRightWheel);
// 后左车轮
const backLeftWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
backLeftWheel.rotation.z = Math.PI / 2;
backLeftWheel.position.set(
-TRACK_PARAMS.railSpacing / 2 + TRACK_PARAMS.railWidth / 2,
TRAIN_PARAMS.wheelRadius,
-TRAIN_PARAMS.bodyLength / 2 + 0.2
);
trainGroup.add(backLeftWheel);
// 后右车轮
const backRightWheel = new THREE.Mesh(wheelGeometry, wheelMaterial);
backRightWheel.rotation.z = Math.PI / 2;
backRightWheel.position.set(
TRACK_PARAMS.railSpacing / 2 - TRACK_PARAMS.railWidth / 2,
TRAIN_PARAMS.wheelRadius,
-TRAIN_PARAMS.bodyLength / 2 + 0.2
);
trainGroup.add(backRightWheel);
// 初始化火车位置(轨道起点)
trainGroup.position.z = 0;
return trainGroup;
}
// ========== 6. 地面创建+元素组装 ==========
// 地面:大平面,衬托轨道
const groundGeometry = new THREE.PlaneGeometry(100, 100);
const groundMaterial = new THREE.MeshStandardMaterial({ color: 0x888888 });
const ground = new THREE.Mesh(groundGeometry, groundMaterial);
ground.rotation.x = -Math.PI / 2; // 旋转为水平地面
ground.position.y = 0;
scene.add(ground);
// 创建轨道和火车,添加到场景
const track = createTrainTrack();
const train = createTrain();
scene.add(track);
scene.add(train);
// ========== 7. 窗口适配 ==========
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
// ========== 8. 动画循环(火车行进+循环) ==========
function animate() {
requestAnimationFrame(animate);
// 火车沿Z轴行进
train.position.z += TRAIN_PARAMS.speed;
// 循环逻辑:到达末端后重置到起点
if (train.position.z > TRACK_PARAMS.trackLength) {
train.position.z = -TRAIN_PARAMS.bodyLength; // 起点略超前,避免突兀
}
controls.update(); // 更新控制器阻尼
renderer.render(scene, camera); // 渲染场景
}
animate();
</script>
</body>
</html>
总结与扩展建议
核心总结
-
模块化设计:通过
Group分组管理轨道/火车的子组件,参数集中化配置,便于维护和扩展; - 几何体精准定位:所有元素的Y轴位置为「高度/2」保证贴地,车轮通过旋转+坐标计算精准贴合钢轨;
-
纹理优化:使用
RepeatWrapping避免纹理拉伸,anisotropy提升斜向清晰度,PBR材质(MeshStandardMaterial)模拟真实质感; -
动画核心:通过
requestAnimationFrame每帧更新火车position.z实现行进,结合边界判断实现循环逻辑。
扩展建议
-
车轮旋转动画:在
animate中添加wheel.rotation.x += 0.1,让车轮随行进旋转,更真实; -
弯曲轨道:修改钢轨分段的
rotation.y和position,实现曲线轨道(需计算圆弧坐标); - 火车细节增强:添加车窗、烟囱、车头等细节,或加载3D模型替代简易几何体;
-
轨道材质优化:启用阴影(
castShadow/receiveShadow),添加钢轨反光、枕木磨损效果; -
交互增强:添加速度控制(滑块调整
TRAIN_PARAMS.speed)、轨道开关、火车启停按钮; -
性能优化:使用
InstancedMesh替代重复创建的钢轨/枕木,减少DrawCall。
Three.js 色彩空间的正确使用方式
three中色彩空间常见用处
// 给材质设置色彩空间
material1.map.colorSpace = THREE.SRGBColorSpace;
// 给渲染器的输出色彩空间, 不设置的话默认值也是SRGBColorSpace
new THREE.WebGLRenderer( { outputColorSpace: THREE.SRGBColorSpace } );
three.js r152+ 之后默认就是
SRGBColorSpace,老版本(outputEncoding时代)行为不同
色彩空间的选项
-
SRGBColorSpace-sRGB 色彩空间
-
LinearSRGBColorSpace-线性sRGB色彩空间
区别?
SRGBColorSpace进行了伽马校正
为什么会有伽马校正?
-
纠正硬件的问题
在液晶显示器普及之前,使用的是笨重的 CRT (阴影 栅格 显像管 ) 电视。CRT 的工作原理是用电子枪射出电子束轰击屏幕,科学家发现,电子枪的电压值和屏幕产生的亮度之间并不是 1:1 的线性关系,而是一个幂函数关系:
也就是如下图中红色曲线所示,跟原本蓝色虚线比较,亮度是偏低的
所以为了还原真实效果,抵消调 CRT压低的亮度,那就把真实亮度数据提高,提高成绿色曲线那样,这样一抵消,显示就正常了,这个提高的过程就是伽马校正
-
也能满足存储空间合理分配
-
人眼特性:我们对暗部的变化非常敏感,而对亮部的变化比较迟钝。
-
数据分配的矛盾:如果我们在电脑里用“线性”方式存储亮度(比如 0 代表黑,128 代表半亮,255 代表全亮):
- 在 0 到 10 之间(暗部),只有 10 个档位。因为我们眼睛太敏感,这 10 个档位之间的跳变看起来会像阶梯一样,非常不自然(这就是“色彩断层”)。
- 在 200 到 250 之间(亮部),虽然有 50 个档位,但我们的眼睛根本分不出这 50 种亮度的区别。这部分昂贵的存储空间(位深)就被浪费了。
-
解决方案(伽马编码) : 故意把 256 个档位中的大部分都分给“暗部”,只留少部分给“亮部”。这样既照顾了人眼的敏感度,又没有浪费存储空间。这样我们可以在 8 位(0-255)的空间里,把更多数值分配给敏感的暗部,让有限的资源发挥最大效用。如图所示(随便找一个以前的暗部区域值,映射后占居的区域明显变多)
为什么现在屏幕“正常”了,还需要它?
现在的液晶(LCD)或 OLED 屏幕完全可以做到“给 128 就亮 50%”,为什么还要折腾?
行业标准的惯性
全球互联网上 99% 的图片(JPEG)、视频(MP4)和网页标准(HTML/CSS)都是基于 sRGB 色彩空间存储的
-
如果显示器突然改为“线性显示”,那么所有的互联网内容看起来都会变得非常亮。
-
并且图片大多还是如图所示8位,需要上面说过的满足存储空间并合理分配
正确色彩空间处理的流程
-
原始图片 默认是 sRGB 色彩空间,它自带一条 “上翘” 的伽马曲线
-
转成 线性空间 :在进入 GPU 运算前,需要先把图片从 sRGB 非线性空间转换为 Linear(线性)sRGB 空间。这一步会把上翘的曲线 “拉平” 成一条直线,让亮度数据恢复成物理上均匀的数值,确保后续的光照、混合等计算结果是准确的。
-
程序运算 :在线性空间里进行渲染计算,比如光影追踪、材质混合、特效合成等。因为线性空间的亮度是均匀的,所以计算出来的光影效果才符合物理规律,不会出现颜色偏差或暗部丢失。
-
渲染结果 :对计算结果,经过伽马校正后,得到的就是最终的 sRGB 格式渲染结果,它的亮度曲线和原始图片的格式是一致的。
-
显示器显示 🖥️显示器接收到 sRGB 信号后,会用它自带的伽马曲线(通常 γ≈2.2)来显示,这个过程会把信号 “压暗”。因为我们已经提前做了伽马校正,所以两次曲线变化刚好抵消,最终显示在屏幕上的亮度就和我们计算的结果完全一致。
-
人眼感知 👀最终画面被人眼看到,色彩和亮度都保持了设计和计算时的真实效果,不会出现过暗或过亮的问题。
总结
也就是我们要保证用来计算的时候是 Linear( 线性空间,用来渲染的时候是sRGB 空间,那在three中如何做到?
Three.js从输入的角度
Three.js中我们只需要指定 色彩空间 类型即可,程序会帮我们转成线性,所以我们要做的就是把应该指定为SRGBColorSpace的纹理,指定为SRGBColorSpace
举几种常见加载器对加载后的图片色彩空间的处理逻辑
TextureLoader
TextureLoader 不设置 colorSpace,保持默认 NoColorSpace,需要手动设置:
注意!颜色纹理需要手动指定色彩空间为SRGBColorSpace,像下文GLTFLoader中的逻辑一样,
例如
const texture = await loader.loadAsync( 'textures/land_ocean_ice_cloud_2048.jpg' );
texture.colorSpace = THREE.SRGBColorSpace;
CubeTextureLoader
CubeTextureLoader 固定设置为 SRGBColorSpace:
GLTFLoader
只有颜色纹理会被设置为 SRGBColorSpace,其他纹理保持 NoColorSpace:
设置为 SRGBColorSpace 的纹理:
-
baseColorTexture (map) → SRGBColorSpace
-
emissiveTexture (emissiveMap) → SRGBColorSpace
-
sheenColorTexture (sheenColorMap) → SRGBColorSpace
-
specularColorTexture (specularColorMap) → SRGBColorSpace
这几种色彩空间标记的处理逻辑
| exture.colorSpace | 内部格式 | sRGB → Linear 转换 |
|---|---|---|
| NoColorSpace(默认) | RGBA8 | 不转换,原样上传 |
| SRGBColorSpace | SRGB8_ALPHA8 | GPU 采样时自动转 SRGB8_ALPHA8 是 WebGL 2.0 的 sRGB 纹理格式 GPU 在采样时自动应用 sRGB EOTF(Electro-Optical Transfer Function)将 sRGB 转为线性 |
| LinearSRGBColorSpace | RGBA8 | 不转换,已是线性 |
也提供了方法可以手动转化,THREE.Color 类下即可调用
src/math/ColorManagement.js
export function SRGBToLinear( c ) {
return ( c < 0.04045 ) ? c * 0.0773993808 : Math.pow( c * 0.9478672986 + 0.0521327014, 2.4 );
}
export function LinearToSRGB( c ) {
return ( c < 0.0031308 ) ? c * 12.92 : 1.055 * ( Math.pow( c, 0.41666 ) ) - 0.055;
}
Three.js中-从输出的角度
threejs会在
- MeshBasicMaterial
- MeshPhysicalMaterial
- MeshPhongMaterial
- MeshLambertMaterial
- MeshToonMaterial
- MeshMatcapMaterial
- SpriteMaterial
- PointsMaterial 等等
材质输出的时候增加这样一段代码
src/renderers/shaders/ShaderChunk/colorspace_fragment.glsl.js
gl_FragColor = linearToOutputTexel( gl_FragColor );
linearToOutputTexel函数会根据outputColorSpace来动态配置
getTexelEncodingFunction( 'linearToOutputTexel', parameters.outputColorSpace ),
说白了就是输出的时候会跟你设置的outputColorSpace来判断需不需要转成SRGBColorSpace,默认是转成SRGBColorSpace
我们自己写的ShaderMaterial输出的时候怎么办
我们也可以调用linearToOutputTexel
因为linearToOutputTexel
-
注入时机:在 WebGLProgram 构造函数中构建 prefixFragment 时
-
注入方式:通过 getTexelEncodingFunction 动态生成函数代码,添加到 prefixFragment
-
可用性:所有非 RawShaderMaterial 的材质(包括 ShaderMaterial)都会自动注入
总结
在 three.js 中,默认的色彩空间配置已经覆盖了大多数使用场景。只要遵循颜色纹理使用 sRGB、渲染计算在 线性空间 、输出再转回 sRGB这一基本原则,画面通常就是正确的。但是理解色彩空间与伽马校正的原理,才能在自定义 Shader、特殊纹理或渲染需求出现时,有意识地手动调整配置,而不是盲目试参数