Files
shengsuan-homepage/src/scene/world.ts
T

703 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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 <beginnormal_vertex>', `
#include <beginnormal_vertex>
{
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 <begin_vertex>', `
#include <begin_vertex>
{
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<string, THREE.Object3D> = {
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()
}
}