703 lines
27 KiB
TypeScript
703 lines
27 KiB
TypeScript
// 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()
|
||
}
|
||
}
|