// world.ts — 单一 WebGL 场景:破碎月球 + 碎片群 + 样条摄影机 + 日出 import * as THREE from 'three' import { EffectComposer } from 'three/examples/jsm/postprocessing/EffectComposer.js' import { RenderPass } from 'three/examples/jsm/postprocessing/RenderPass.js' import { UnrealBloomPass } from 'three/examples/jsm/postprocessing/UnrealBloomPass.js' import { rig } from './rig' import { C, MOON_R, GAP_DIR, FRAG_A, FRAG_B, FRAG_C, SUN_POS, POSES, SEGMENTS, } from '../config/stage' // ---------- 工具:确定性伪随机 + 值噪声 ---------- function hash3(x: number, y: number, z: number) { const s = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453 return s - Math.floor(s) } function vnoise(x: number, y: number, z: number) { const xi = Math.floor(x), yi = Math.floor(y), zi = Math.floor(z) const xf = x - xi, yf = y - yi, zf = z - zi const u = xf * xf * (3 - 2 * xf), v = yf * yf * (3 - 2 * yf), w = zf * zf * (3 - 2 * zf) let acc = 0 for (let i = 0; i < 8; i++) { const dx = i & 1, dy = (i >> 1) & 1, dz = (i >> 2) & 1 const h = hash3(xi + dx, yi + dy, zi + dz) acc += h * (dx ? u : 1 - u) * (dy ? v : 1 - v) * (dz ? w : 1 - w) } return acc } function fbm(x: number, y: number, z: number, oct = 3) { let a = 0, amp = 0.5, f = 1 for (let i = 0; i < oct; i++) { a += vnoise(x * f, y * f, z * f) * amp amp *= 0.5 f *= 2.1 } return a } function mulberry32(seed: number) { return () => { seed |= 0; seed = (seed + 0x6d2b79f5) | 0 let t = Math.imul(seed ^ (seed >>> 15), 1 | seed) t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t return ((t ^ (t >>> 14)) >>> 0) / 4294967296 } } const smoothstep = (a: number, b: number, x: number) => { const t = Math.min(1, Math.max(0, (x - a) / (b - a))) return t * t * (3 - 2 * t) } // ---------- 机位样条 ---------- const N = POSES.length const posCurve = new THREE.CatmullRomCurve3( POSES.map((p) => new THREE.Vector3(...p.pos)), false, 'centripetal', ) const lookCurve = new THREE.CatmullRomCurve3( POSES.map((p) => new THREE.Vector3(...p.look)), false, 'centripetal', ) // content t → 样条参数 u(命中机位点) function tToU(t: number) { if (t <= POSES[0].t) return 0 for (let i = 0; i < N - 1; i++) { if (t <= POSES[i + 1].t) { const f = (t - POSES[i].t) / (POSES[i + 1].t - POSES[i].t || 1) return (i + f) / (N - 1) } } return 1 } // 全局进度 → u(按段落性格施加缓动) function contentToU(t: number) { for (const s of SEGMENTS) { if (t >= s.t0 && t <= s.t1) { const lt = (t - s.t0) / (s.t1 - s.t0 || 1) const u0 = tToU(s.t0), u1 = tToU(s.t1) return u0 + (u1 - u0) * s.ease(Math.min(1, Math.max(0, lt))) } } return t >= 1 ? 1 : 0 } // ---------- 材质辅助 ---------- function rockMaterial(color: string, vertexColors = false) { return new THREE.MeshStandardMaterial({ color, roughness: 0.96, metalness: 0.04, flatShading: false, vertexColors, }) } // 太阳 / 光轴贴图(canvas 程序生成) function radialTexture(inner: string, outer: string) { const cv = document.createElement('canvas') cv.width = cv.height = 256 const g = cv.getContext('2d')! const grad = g.createRadialGradient(128, 128, 0, 128, 128, 128) grad.addColorStop(0, inner) grad.addColorStop(0.35, outer) grad.addColorStop(1, 'rgba(0,0,0,0)') g.fillStyle = grad g.fillRect(0, 0, 256, 256) const tx = new THREE.CanvasTexture(cv) return tx } function shaftTexture() { const cv = document.createElement('canvas') cv.width = 256; cv.height = 64 const g = cv.getContext('2d')! const grad = g.createLinearGradient(0, 0, 256, 0) grad.addColorStop(0, 'rgba(232,201,122,0)') grad.addColorStop(0.25, 'rgba(232,201,122,0.5)') grad.addColorStop(0.75, 'rgba(232,201,122,0.35)') grad.addColorStop(1, 'rgba(232,201,122,0)') g.fillStyle = grad g.fillRect(0, 0, 256, 64) // 纵向羽化 const im = g.getImageData(0, 0, 256, 64) for (let y = 0; y < 64; y++) { const f = 1 - Math.abs(y - 32) / 32 for (let x = 0; x < 256; x++) im.data[(y * 256 + x) * 4 + 3] *= f * f } g.putImageData(im, 0, 0) return new THREE.CanvasTexture(cv) } // ========================================================================== export function initWorld(canvas: HTMLCanvasElement) { const renderer = new THREE.WebGLRenderer({ canvas, antialias: true, powerPreference: 'high-performance', }) renderer.setPixelRatio(Math.min(window.devicePixelRatio, 1.5)) renderer.setSize(window.innerWidth, window.innerHeight) renderer.toneMapping = THREE.ACESFilmicToneMapping renderer.toneMappingExposure = 1.05 const scene = new THREE.Scene() scene.background = new THREE.Color(C.space) scene.fog = new THREE.FogExp2(C.fog, 0.008) const camera = new THREE.PerspectiveCamera( 45, window.innerWidth / window.innerHeight, 0.1, 600, ) camera.position.set(-7, 4, 46) camera.lookAt(3, -3, 0) // ---------- 灯光(全片冷色纪律) ---------- scene.add(new THREE.AmbientLight(0x33465e, 1.1)) const keyLight = new THREE.DirectionalLight(0xcfe8f0, 1.6) keyLight.position.set(30, 25, 40) scene.add(keyLight) const rimLight = new THREE.DirectionalLight(0x9fd8e8, 1.0) rimLight.position.set(-25, -8, -45) scene.add(rimLight) // 终章暖阳(日出驱动) const sunLight = new THREE.DirectionalLight(C.dawn, 0) sunLight.position.copy(SUN_POS) scene.add(sunLight) // W2 山地局部冷光(让岩壁在雾中可读,不影响太空机位) const groundLight = new THREE.PointLight(0xd8ecf4, 16, 40, 1.6) groundLight.position.set(26, -5.5, 28) scene.add(groundLight) // ---------- 星空底 ---------- { const n = 1400 const pos = new Float32Array(n * 3) const rnd = mulberry32(7) for (let i = 0; i < n; i++) { const r = 220 + rnd() * 160 const th = rnd() * Math.PI * 2, ph = Math.acos(2 * rnd() - 1) pos[i * 3] = r * Math.sin(ph) * Math.cos(th) pos[i * 3 + 1] = r * Math.cos(ph) * 0.6 pos[i * 3 + 2] = r * Math.sin(ph) * Math.sin(th) } const g = new THREE.BufferGeometry() g.setAttribute('position', new THREE.BufferAttribute(pos, 3)) const m = new THREE.PointsMaterial({ color: 0xcfe8f0, size: 1.1, sizeAttenuation: false, transparent: true, opacity: 0.65, fog: false, }) scene.add(new THREE.Points(g, m)) } // 微弱星云 { const tx = radialTexture('rgba(70,110,140,0.35)', 'rgba(40,70,100,0.12)') const rnd = mulberry32(11) for (let i = 0; i < 4; i++) { const sp = new THREE.Sprite(new THREE.SpriteMaterial({ map: tx, transparent: true, opacity: 0.35, depthWrite: false, fog: false, })) sp.position.set((rnd() - 0.5) * 300, (rnd() - 0.5) * 160, -180 - rnd() * 120) sp.scale.setScalar(180 + rnd() * 160) scene.add(sp) } } // ---------- 月球(破碎——全片最重要的资产) ---------- const moon = new THREE.Group() { const geo = new THREE.IcosahedronGeometry(MOON_R, 32) const p = geo.attributes.position const colors = new Float32Array(p.count * 3) const lit = new THREE.Color(C.moonLit) const frac = new THREE.Color(C.fracture) const tmp = new THREE.Vector3() const cosGap = Math.cos(0.72) // 缺口半角 ~41° for (let i = 0; i < p.count; i++) { tmp.fromBufferAttribute(p, i) const dir = tmp.clone().normalize() // 基础岩石起伏 let r = MOON_R + (fbm(dir.x * 2.3 + 9, dir.y * 2.3, dir.z * 2.3) - 0.5) * 0.9 // 缺口:一侧整体缺失,顶点位向内扣 const d = dir.dot(GAP_DIR) let depth = 0 if (d > cosGap) { depth = smoothstep(cosGap, 1, d) const jag = (fbm(dir.x * 6 + 40, dir.y * 6, dir.z * 6) - 0.5) * 2.2 r -= depth * (7.2 + jag) } tmp.copy(dir).multiplyScalar(r) p.setXYZ(i, tmp.x, tmp.y, tmp.z) // 顶点色:断面深色 + 月表灰蓝 const cc = depth > 0.02 ? frac.clone().lerp(lit, Math.max(0, 0.25 - depth * 0.25)) : lit.clone().multiplyScalar(0.75 + fbm(dir.x * 4, dir.y * 4 + 3, dir.z * 4) * 0.5) colors[i * 3] = cc.r; colors[i * 3 + 1] = cc.g; colors[i * 3 + 2] = cc.b } geo.setAttribute('color', new THREE.BufferAttribute(colors, 3)) geo.computeVertexNormals() moon.add(new THREE.Mesh(geo, rockMaterial('#ffffff', true))) } // 断裂环(锯齿状碎边轮廓) { const ringR = Math.sin(0.72) * MOON_R * 0.96 const center = GAP_DIR.clone().multiplyScalar(MOON_R - 5.6) const u = new THREE.Vector3(0, 1, 0).cross(GAP_DIR).normalize() const v = GAP_DIR.clone().cross(u).normalize() const n = 46 const inst = new THREE.InstancedMesh( new THREE.TetrahedronGeometry(0.85), rockMaterial(C.fracture), n, ) const rnd = mulberry32(23) const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler() for (let i = 0; i < n; i++) { const a = (i / n) * Math.PI * 2 const rr = ringR * (0.9 + rnd() * 0.25) const pt = center.clone() .addScaledVector(u, Math.cos(a) * rr) .addScaledVector(v, Math.sin(a) * rr) .addScaledVector(GAP_DIR, (rnd() - 0.5) * 1.6) e.set(rnd() * Math.PI, rnd() * Math.PI, rnd() * Math.PI) q.setFromEuler(e) m4.compose(pt, q, new THREE.Vector3().setScalar(0.5 + rnd() * 1.1)) inst.setMatrixAt(i, m4) } moon.add(inst) } // 缺口内部尘埃(叙事焦点的生命力) let gapDust: THREE.Points { const n = 180 const pos = new Float32Array(n * 3) const rnd = mulberry32(31) const u = new THREE.Vector3(0, 1, 0).cross(GAP_DIR).normalize() const v = GAP_DIR.clone().cross(u).normalize() for (let i = 0; i < n; i++) { const rr = rnd() * 4.5 const a = rnd() * Math.PI * 2 const pt = GAP_DIR.clone().multiplyScalar(4 + rnd() * 5) .addScaledVector(u, Math.cos(a) * rr) .addScaledVector(v, Math.sin(a) * rr) pos[i * 3] = pt.x; pos[i * 3 + 1] = pt.y; pos[i * 3 + 2] = pt.z } const g = new THREE.BufferGeometry() g.setAttribute('position', new THREE.BufferAttribute(pos, 3)) gapDust = new THREE.Points(g, new THREE.PointsMaterial({ color: 0x7fe7ff, size: 0.12, transparent: true, opacity: 0.5, blending: THREE.AdditiveBlending, depthWrite: false, })) moon.add(gapDust) } scene.add(moon) // ---------- 远景碎片群(InstancedMesh + 着色器失重悬浮) ---------- const debrisTime = { value: 0 } { const n = 56 const geo = new THREE.IcosahedronGeometry(1, 1) const mat = rockMaterial(C.moonLit) mat.flatShading = true mat.onBeforeCompile = (sh) => { sh.uniforms.uTime = debrisTime as never sh.vertexShader = ` uniform float uTime; attribute vec3 aAxis; attribute float aRotP; attribute float aBobA; attribute float aBobP; attribute float aPhase; attribute vec3 aDrift; ` + sh.vertexShader .replace('#include ', ` #include { float ang = uTime / aRotP * 6.2831853 + aPhase; vec3 ax = normalize(aAxis); float ca = cos(ang), sa = sin(ang); objectNormal = objectNormal * ca + cross(ax, objectNormal) * sa + ax * dot(ax, objectNormal) * (1.0 - ca); } `) .replace('#include ', ` #include { float ang = uTime / aRotP * 6.2831853 + aPhase; vec3 ax = normalize(aAxis); float ca = cos(ang), sa = sin(ang); transformed = transformed * ca + cross(ax, transformed) * sa + ax * dot(ax, transformed) * (1.0 - ca); transformed.y += sin(uTime / aBobP * 6.2831853 + aPhase) * aBobA; transformed += aDrift * sin(uTime * 0.04 + aPhase * 1.7); } `) } const inst = new THREE.InstancedMesh(geo, mat, n) const rnd = mulberry32(47) const u = new THREE.Vector3(0, 1, 0).cross(GAP_DIR).normalize() const v = GAP_DIR.clone().cross(u).normalize() const m4 = new THREE.Matrix4(), q = new THREE.Quaternion() const axis = new Float32Array(n * 3), rotP = new Float32Array(n) const bobA = new Float32Array(n), bobP = new Float32Array(n) const phase = new Float32Array(n), drift = new Float32Array(n * 3) for (let i = 0; i < n; i++) { const dist = 14 + Math.pow(rnd(), 1.4) * 70 const spread = 3 + dist * 0.35 const pt = GAP_DIR.clone().multiplyScalar(dist) .addScaledVector(u, (rnd() - 0.5) * spread) .addScaledVector(v, (rnd() - 0.5) * spread) const s = 0.35 + rnd() * rnd() * 2.0 q.identity() m4.compose(pt, q, new THREE.Vector3(s, s * (0.7 + rnd() * 0.6), s)) inst.setMatrixAt(i, m4) const ax = new THREE.Vector3(rnd() - 0.5, rnd() - 0.5, rnd() - 0.5).normalize() axis[i * 3] = ax.x; axis[i * 3 + 1] = ax.y; axis[i * 3 + 2] = ax.z rotP[i] = 20 + rnd() * 20 bobA[i] = s * (0.02 + rnd() * 0.03) bobP[i] = 20 + rnd() * 20 phase[i] = rnd() * Math.PI * 2 const dv = GAP_DIR.clone().multiplyScalar(0.5 + rnd() * 1.5) drift[i * 3] = dv.x; drift[i * 3 + 1] = dv.y; drift[i * 3 + 2] = dv.z } geo.setAttribute('aAxis', new THREE.InstancedBufferAttribute(axis, 3)) geo.setAttribute('aRotP', new THREE.InstancedBufferAttribute(rotP, 1)) geo.setAttribute('aBobA', new THREE.InstancedBufferAttribute(bobA, 1)) geo.setAttribute('aBobP', new THREE.InstancedBufferAttribute(bobP, 1)) geo.setAttribute('aPhase', new THREE.InstancedBufferAttribute(phase, 1)) geo.setAttribute('aDrift', new THREE.InstancedBufferAttribute(drift, 3)) scene.add(inst) } // ---------- 碎片 A · 不周山(尺度戏法:近看=山地) ---------- const fragA = new THREE.Group() { const geo = new THREE.IcosahedronGeometry(4.5, 20) const p = geo.attributes.position const tmp = new THREE.Vector3() for (let i = 0; i < p.count; i++) { tmp.fromBufferAttribute(p, i) const dir = tmp.clone().normalize() const r = 4.5 + (fbm(dir.x * 3 + 60, dir.y * 3, dir.z * 3) - 0.5) * 1.6 p.setXYZ(i, dir.x * r, dir.y * r, dir.z * r) } geo.computeVertexNormals() fragA.add(new THREE.Mesh(geo, rockMaterial(C.moonLit))) fragA.position.copy(FRAG_A) scene.add(fragA) } // 山地地表(高细节置换 + 浓雾截断视野) let groundMesh: THREE.Mesh, cliffMesh: THREE.Mesh, gravel: THREE.InstancedMesh { const geo = new THREE.PlaneGeometry(90, 90, 110, 110) geo.rotateX(-Math.PI / 2) const p = geo.attributes.position for (let i = 0; i < p.count; i++) { const x = p.getX(i), z = p.getZ(i) const h = (fbm(x * 0.12 + 80, 0, z * 0.12, 4) - 0.5) * 3.2 + (fbm(x * 0.8 + 120, 0, z * 0.8, 3) - 0.5) * 0.7 p.setY(i, h) } geo.computeVertexNormals() const ground = new THREE.Mesh(geo, rockMaterial('#3d4c5e')) ground.position.set(30, -8.3, 25) ground.visible = false // 尺度戏法:仅 W2 附近可见(远看隐藏,防穿帮) scene.add(ground) groundMesh = ground } // 岩壁(古文锚点所在,预留较平整区域朝向镜头) let cliffAnchor: THREE.Object3D { const geo = new THREE.PlaneGeometry(40, 20, 90, 45) const p = geo.attributes.position for (let i = 0; i < p.count; i++) { const x = p.getX(i), y = p.getY(i) // 中央区(古文区)平整化 const flat = smoothstep(4, 9, Math.abs(x)) * smoothstep(2.5, 6, Math.abs(y + 1)) const h = (fbm(x * 0.3 + 200, y * 0.3, 0, 4) - 0.5) * 2.6 * (0.3 + 0.7 * flat) p.setZ(i, h) } geo.computeVertexNormals() const cliff = new THREE.Mesh(geo, rockMaterial('#4e6178')) cliff.rotation.y = -Math.PI / 2 // 法线朝 -X,面向 W2 机位 cliff.position.set(28.6, -1.8, 25) cliff.visible = false cliffMesh = cliff scene.add(cliff) cliffMesh = cliff cliffAnchor = new THREE.Object3D() cliffAnchor.position.set(28.2, -4.1, 25) scene.add(cliffAnchor) } // 近景砾石(W2 接近时淡入) { const n = rig.state.isMobile ? 90 : 200 gravel = new THREE.InstancedMesh( new THREE.DodecahedronGeometry(0.14), rockMaterial('#4a5a6e'), n, ) const rnd = mulberry32(71) const m4 = new THREE.Matrix4(), q = new THREE.Quaternion(), e = new THREE.Euler() for (let i = 0; i < n; i++) { const a = rnd() * Math.PI * 2, r = 1 + rnd() * 16 const pt = new THREE.Vector3(20 + Math.cos(a) * r, -8.05, 25 + Math.sin(a) * r * 0.8) e.set(rnd() * 3, rnd() * 3, rnd() * 3) q.setFromEuler(e) m4.compose(pt, q, new THREE.Vector3().setScalar(0.4 + rnd() * 1.6)) gravel.setMatrixAt(i, m4) } gravel.visible = false scene.add(gravel) } // ---------- 碎片 B · 高等文明遗迹 ---------- const fragB = new THREE.Group() let relicMat: THREE.MeshStandardMaterial, relicLines: THREE.LineSegments { const rock = new THREE.Mesh( (() => { const geo = new THREE.IcosahedronGeometry(3.5, 16) const p = geo.attributes.position const tmp = new THREE.Vector3() for (let i = 0; i < p.count; i++) { tmp.fromBufferAttribute(p, i) const dir = tmp.clone().normalize() const r = 3.5 + (fbm(dir.x * 3 + 90, dir.y * 3, dir.z * 3) - 0.5) * 1.2 p.setXYZ(i, dir.x * r, dir.y * r, dir.z * r) } geo.computeVertexNormals() return geo })(), rockMaterial(C.moonLit), ) fragB.add(rock) // 半埋环形构件:硬边、金属、发光纹路 relicMat = new THREE.MeshStandardMaterial({ color: 0x2b3644, roughness: 0.3, metalness: 0.9, emissive: new THREE.Color(C.relic), emissiveIntensity: 0, }) const torus = new THREE.Mesh(new THREE.TorusGeometry(3.0, 0.28, 10, 56, Math.PI * 1.4), relicMat) torus.rotation.set(1.25, 0.35, 0.5) torus.position.set(0.4, 1.1, 0.2) fragB.add(torus) const poly = new THREE.Mesh(new THREE.IcosahedronGeometry(1.05, 0), relicMat) poly.position.set(0.4, 1.3, 0.2) fragB.add(poly) relicLines = new THREE.LineSegments( new THREE.EdgesGeometry(new THREE.IcosahedronGeometry(1.6, 0)), new THREE.LineBasicMaterial({ color: C.relic, transparent: true, opacity: 0 }), ) relicLines.position.set(0.4, 1.3, 0.2) fragB.add(relicLines) fragB.position.copy(FRAG_B) scene.add(fragB) } const anchorB = new THREE.Object3D() anchorB.position.copy(FRAG_B).add(new THREE.Vector3(0.4, 2.3, 0.2)) scene.add(anchorB) // ---------- 碎片 C · 同源(岩壁刻痕与发光构件各半) ---------- const fragC = new THREE.Group() let convMat: THREE.MeshStandardMaterial { const geo = new THREE.IcosahedronGeometry(3.4, 16) const p = geo.attributes.position const tmp = new THREE.Vector3() for (let i = 0; i < p.count; i++) { tmp.fromBufferAttribute(p, i) const dir = tmp.clone().normalize() // 只有 x<0 半侧做有机起伏;x>0 半侧保持硬边(机械感) const organic = dir.x < 0 ? (fbm(dir.x * 3 + 140, dir.y * 3, dir.z * 3) - 0.5) * 1.3 : 0.1 const r = 3.4 + organic - (dir.x < 0 ? 0 : 0.35) p.setXYZ(i, dir.x * r, dir.y * r, dir.z * r) } geo.computeVertexNormals() { const m = rockMaterial(C.moonLit); m.flatShading = true; fragC.add(new THREE.Mesh(geo, m)) } convMat = new THREE.MeshStandardMaterial({ color: 0x2b3644, roughness: 0.3, metalness: 0.9, emissive: new THREE.Color(C.relic), emissiveIntensity: 0.05, }) const ring = new THREE.Mesh(new THREE.TorusGeometry(1.9, 0.16, 8, 40, Math.PI), convMat) ring.position.set(3.0, 0.5, 0) ring.rotation.set(0.2, Math.PI / 2, 0.75) fragC.add(ring) const shard = new THREE.Mesh(new THREE.OctahedronGeometry(0.8, 0), convMat) shard.position.set(3.1, 1.6, 0.5) fragC.add(shard) fragC.position.copy(FRAG_C) scene.add(fragC) } const anchorC = new THREE.Object3D() anchorC.position.copy(FRAG_C).add(new THREE.Vector3(0.5, 2.2, 0.5)) scene.add(anchorC) // ---------- 太阳与光轴(终章) ---------- const sunSprite = new THREE.Sprite(new THREE.SpriteMaterial({ map: radialTexture('rgba(255,244,214,1)', 'rgba(232,201,122,0.55)'), transparent: true, opacity: 0, depthWrite: false, fog: false, blending: THREE.AdditiveBlending, })) sunSprite.scale.setScalar(34) scene.add(sunSprite) const sunStart = SUN_POS.clone().add(new THREE.Vector3(-26, 18, 0)) // 画面左上外缘 const shaftCount = rig.state.isMobile ? 3 : 5 const shafts: THREE.Mesh[] = [] { const tx = shaftTexture() const d = SUN_POS.clone().negate().normalize() // 光轴行进方向 for (let i = 0; i < shaftCount; i++) { const m = new THREE.Mesh( new THREE.PlaneGeometry(180, 5.5), new THREE.MeshBasicMaterial({ map: tx, transparent: true, opacity: 0, fog: false, blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide, color: C.dawn, }), ) m.quaternion.setFromUnitVectors(new THREE.Vector3(1, 0, 0), d) m.rotateX(i * 1.1 + 0.4) const off = new THREE.Vector3( (i - shaftCount / 2) * 7, (i % 2) * 6 - 3, ((i * 37) % 11) - 5, ) m.position.copy(d.clone().multiplyScalar(45)).add(off) shafts.push(m) scene.add(m) } } // ---------- 后处理:Bloom(常态克制,终章渐强) ---------- const composer = new EffectComposer(renderer) composer.addPass(new RenderPass(scene, camera)) const bloom = new UnrealBloomPass( new THREE.Vector2(window.innerWidth, window.innerHeight), 0.32, 0.55, 0.85, ) composer.addPass(bloom) // ---------- 每帧更新 ---------- const tmpV = new THREE.Vector3() const tmpLook = new THREE.Vector3() const qYaw = new THREE.Quaternion(), qPitch = new THREE.Quaternion() const AXIS_Y = new THREE.Vector3(0, 1, 0), AXIS_X = new THREE.Vector3(1, 0, 0) const clock = new THREE.Clock() let raf = 0 let fogTarget = 0.008 const anchorsMap: Record = { W2: cliffAnchor!, W4: anchorB, W6: anchorC, } function frame() { raf = requestAnimationFrame(frame) const dt = Math.min(0.05, clock.getDelta()) const time = clock.elapsedTime const s = rig.state rig.update(dt, time) debrisTime.value = time // --- 1. 滚动层:样条机位 --- const u = contentToU(s.content) posCurve.getPoint(u, tmpV) lookCurve.getPoint(u, tmpLook) const inDwell = s.seg.type === 'dwell' // idle 呼吸浮动(驻留段,±0.05,周期 ~5.5s) if (!s.reduced) { const idleAmp = inDwell ? 0.055 : 0.02 tmpV.y += Math.sin(time * (Math.PI * 2 / 5.5)) * idleAmp tmpV.x += Math.sin(time * (Math.PI * 2 / 7.3) + 1.7) * idleAmp * 0.6 // 蛇形接近(T3/T5) if (s.seg.sway) { const sway = Math.sin(s.segT * Math.PI * 2.2) * 0.7 const side = new THREE.Vector3().subVectors(tmpV, tmpLook) .cross(AXIS_Y).normalize() tmpV.addScaledVector(side, sway) } // W1 故障期镜头抖动 if (s.seg.id === 'W1' && s.glitchClock >= 0) { const gc = s.glitchClock const amp = gc < 1.5 ? 0.14 * (1 - gc / 1.8) : 0.02 tmpV.x += (hash3(Math.floor(time * 24), 1, 0) - 0.5) * amp tmpV.y += (hash3(Math.floor(time * 24), 2, 0) - 0.5) * amp } } camera.position.copy(tmpV) camera.lookAt(tmpLook) // --- 2. 时间层:天体失重悬浮 --- if (!s.reduced) { moon.rotation.y += dt * (Math.PI * 2 / 240) moon.rotation.z = 0.04 gapDust.rotation.z += dt * 0.05 // 叙事碎片:多轴翻滚(主副周期不取整数比) const tA = time * (Math.PI * 2 / 58) fragA.rotation.set(Math.sin(tA * 0.45) * 0.1, tA * 0.12, Math.sin(tA / 1.9) * 0.08) fragA.position.y = FRAG_A.y + Math.sin(time * (Math.PI * 2 / 47) + 1.2) * 0.09 const tB = time * (Math.PI * 2 / 64) fragB.rotation.set(Math.sin(tB * 0.4) * 0.12, tB * 0.1, Math.sin(tB / 2.1) * 0.1) fragB.position.y = FRAG_B.y + Math.sin(time * (Math.PI * 2 / 41) + 3.1) * 0.08 const tC = time * (Math.PI * 2 / 52) fragC.rotation.set(Math.sin(tC * 0.5) * 0.11, -tC * 0.11, Math.sin(tC / 1.7) * 0.09) fragC.position.y = FRAG_C.y + Math.sin(time * (Math.PI * 2 / 44) + 5.0) * 0.08 } // --- 3. 指针层:相机姿态 × 指针偏移(以旋转为主) --- if (!s.reduced && !s.isMobile) { let amp = inDwell ? 1 : 0.4 if (s.seg.id === 'W1' && s.glitchActive) amp = 0.15 if (s.seg.id === 'W7') amp = 0.6 amp *= s.pointerActive const yaw = -s.pointerDamped.x * THREE.MathUtils.degToRad(2.5) * amp const pitch = -s.pointerDamped.y * THREE.MathUtils.degToRad(1.5) * amp qYaw.setFromAxisAngle(AXIS_Y, yaw) qPitch.setFromAxisAngle(AXIS_X, pitch) camera.quaternion.multiply(qYaw).multiply(qPitch) } // --- 雾密度分段驱动 --- const segId = s.seg.id fogTarget = segId === 'W2' ? 0.02 : segId === 'W4' || segId === 'W6' ? 0.011 : segId === 'W7' ? 0.006 : 0.008 const fog = scene.fog as THREE.FogExp2 fog.density += (fogTarget - fog.density) * Math.min(1, dt * 2.5) // --- 山地资产按距离淡入/出(尺度戏法 LOD,防远景穿帮) --- const nearW2 = s.content > 0.24 && s.content < 0.56 groundMesh.visible = nearW2 cliffMesh.visible = nearW2 gravel.visible = s.content > 0.28 && s.content < 0.5 // --- W4 遗迹发光(滚动驱动 0→1) --- const w4Glow = segId === 'W4' ? smoothstep(0.08, 0.55, s.segT) : segId === 'T4' || segId === 'W5' ? 1 : 0 relicMat.emissiveIntensity = w4Glow * 0.85 ;(relicLines.material as THREE.LineBasicMaterial).opacity = w4Glow * 0.9 convMat.emissiveIntensity = 0.05 + (segId === 'W6' ? smoothstep(0.1, 0.6, s.segT) * 0.8 : 0) // --- 终章日出 --- const sun = s.sunrise sunSprite.position.lerpVectors(sunStart, SUN_POS, smoothstep(0, 0.5, sun)) sunSprite.material.opacity = Math.min(1, sun * 1.6) sunLight.intensity = sun * 2.3 for (let i = 0; i < shafts.length; i++) { // 逐束点亮:第 1 束 30%,之后每 +15% 一束 const o = smoothstep(0.3 + i * 0.15, 0.3 + i * 0.15 + 0.12, sun) ;(shafts[i].material as THREE.MeshBasicMaterial).opacity = o * 0.4 } bloom.strength = 0.32 + sun * 0.55 bloom.threshold = 0.85 - sun * 0.16 renderer.toneMappingExposure = 1.05 + sun * 0.12 // --- 3D 锚点 → 屏幕投影(DOM 文字层跟随) --- for (const [key, obj] of Object.entries(anchorsMap)) { tmpV.setFromMatrixPosition(obj.matrixWorld).project(camera) const behind = tmpV.z > 1 s.anchors[key] = { x: (tmpV.x * 0.5 + 0.5) * window.innerWidth, y: (-tmpV.y * 0.5 + 0.5) * window.innerHeight, visible: !behind && s.seg.id === key, } } composer.render() } frame() const onResize = () => { camera.aspect = window.innerWidth / window.innerHeight camera.updateProjectionMatrix() renderer.setSize(window.innerWidth, window.innerHeight) composer.setSize(window.innerWidth, window.innerHeight) } window.addEventListener('resize', onResize) return () => { cancelAnimationFrame(raf) window.removeEventListener('resize', onResize) renderer.dispose() } }