太空兔子
Post to social channels, or use Markdown and badges for GitHub/README.
[](/en/r/rWVW2L5uhu)
Amber: Thinking TPS. Green: Output TPS. Live estimates while running; final values match the card’s Output TPS. Speed is not answer quality.
这是可直接运行的单文件 HTML Web3D 作品,呈现 Space Bunny 在梦幻太空海岸蹦跳、战斗机掠海悬停的温暖场景,打开即可沉浸欣赏。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<title>Space Bunny · just breathe</title>
<style>
html, body {
margin: 0; padding: 0; width: 100%; height: 100%;
overflow: hidden; background: #05041a;
}
canvas { display: block; }
/* 轻微暗角,让画面更有电影感 */
#vignette {
position: fixed; inset: 0; pointer-events: none; z-index: 10;
background: radial-gradient(ellipse at 50% 45%,
rgba(0,0,0,0) 40%,
rgba(8,4,26,0.35) 78%,
rgba(4,2,16,0.72) 100%);
}
#loading {
position: fixed; inset: 0; z-index: 20; display: flex;
align-items: center; justify-content: center;
background: #05041a; color: rgba(190,210,255,0.65);
font-family: "Helvetica Neue", Arial, "PingFang SC", sans-serif;
font-size: 12px; letter-spacing: 0.5em; text-transform: uppercase;
transition: opacity 1.2s ease;
}
#loading.hide { opacity: 0; pointer-events: none; }
</style>
</head>
<body>
<div id="vignette"></div>
<div id="loading">space bunny</div>
<script type="importmap">
{
"imports": {
"three": "https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/": "https://unpkg.com/three@0.160.0/examples/jsm/"
}
}
</script>
<script type="module">
import * as THREE from 'three';
import { Reflector } from 'three/addons/objects/Reflector.js';
import { EffectComposer } from 'three/addons/postprocessing/EffectComposer.js';
import { RenderPass } from 'three/addons/postprocessing/RenderPass.js';
import { UnrealBloomPass } from 'three/addons/postprocessing/UnrealBloomPass.js';
import { OutputPass } from 'three/addons/postprocessing/OutputPass.js';
/* =========================================================
0. 基础环境
========================================================= */
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
renderer.setSize(window.innerWidth, window.innerHeight);
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x1b1238, 0.0055);
const camera = new THREE.PerspectiveCamera(45, window.innerWidth / window.innerHeight, 0.1, 3000);
camera.position.set(0.8, 2.2, 9.2);
const clock = new THREE.Clock();
let time = 0;
/* =========================================================
1. 程序化贴图工具
========================================================= */
function makeGlowTexture(size = 128) {
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
const g = ctx.createRadialGradient(size / 2, size / 2, 0, size / 2, size / 2, size / 2);
g.addColorStop(0.0, 'rgba(255,255,255,1)');
g.addColorStop(0.35, 'rgba(190,215,255,0.42)');
g.addColorStop(1.0, 'rgba(120,150,255,0)');
ctx.fillStyle = g;
ctx.fillRect(0, 0, size, size);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
function makeCloudTexture() {
const size = 256;
const c = document.createElement('canvas');
c.width = c.height = size;
const ctx = c.getContext('2d');
for (let i = 0; i < 8; i++) {
const x = size * 0.5 + (Math.random() - 0.5) * size * 0.52;
const y = size * 0.5 + (Math.random() - 0.5) * size * 0.34;
const r = size * (0.15 + Math.random() * 0.17);
const g = ctx.createRadialGradient(x, y, 0, x, y, r);
g.addColorStop(0.0, 'rgba(255,255,255,0.70)');
g.addColorStop(0.5, 'rgba(255,255,255,0.26)');
g.addColorStop(1.0, 'rgba(255,255,255,0)');
ctx.fillStyle = g;
ctx.beginPath();
ctx.arc(x, y, r, 0, Math.PI * 2);
ctx.fill();
}
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
return t;
}
function makeTextTexture(text) {
const W = 1024, H = 256;
const c = document.createElement('canvas');
c.width = W; c.height = H;
const ctx = c.getContext('2d');
ctx.clearRect(0, 0, W, H);
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.font = '300 108px "Helvetica Neue", Helvetica, Arial, sans-serif';
// 多层辉光
ctx.shadowColor = 'rgba(140,205,255,0.95)';
ctx.shadowBlur = 60;
ctx.fillStyle = 'rgba(200,230,255,0.55)';
ctx.fillText(text, W / 2, H / 2);
ctx.fillText(text, W / 2, H / 2);
ctx.shadowBlur = 26;
ctx.fillStyle = 'rgba(235,245,255,0.9)';
ctx.fillText(text, W / 2, H / 2);
// 实心白
ctx.shadowBlur = 0;
ctx.fillStyle = '#ffffff';
ctx.fillText(text, W / 2, H / 2);
const t = new THREE.CanvasTexture(c);
t.colorSpace = THREE.SRGBColorSpace;
t.anisotropy = 4;
return t;
}
const glowTex = makeGlowTexture();
const cloudTex = makeCloudTexture();
/* =========================================================
2. 天空(深蓝 → 紫 渐变球)
========================================================= */
const skyMaterial = new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
fog: false,
uniforms: {
uTop: { value: new THREE.Color('#080a2e') },
uMid: { value: new THREE.Color('#2d1f6e') },
uBot: { value: new THREE.Color('#6b2f7d') },
uGlow: { value: new THREE.Color('#a855c8') }
},
vertexShader: /* glsl */`
varying vec3 vDir;
void main() {
vDir = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: /* glsl */`
uniform vec3 uTop;
uniform vec3 uMid;
uniform vec3 uBot;
uniform vec3 uGlow;
varying vec3 vDir;
void main() {
vec3 d = normalize(vDir);
float h = d.y;
vec3 col = mix(uBot, uMid, smoothstep(-0.28, 0.26, h));
col = mix(col, uTop, smoothstep(0.20, 0.88, h));
// 地平线紫粉色光晕
col += uGlow * exp(-abs(h) * 6.5) * 0.42;
// 贴近地平线的冷青色薄雾
col += vec3(0.10, 0.22, 0.46) * exp(-abs(h) * 20.0) * 0.85;
// 极轻微抖动,消除色带
float n = fract(sin(dot(d.xy * 91.7, vec2(12.9898, 78.233))) * 43758.5453);
col += (n - 0.5) * 0.014;
gl_FragColor = vec4(col, 1.0);
}
`
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(800, 48, 32), skyMaterial);
scene.add(sky);
/* =========================================================
3. 星星
========================================================= */
(function buildStars() {
const COUNT = 1100;
const positions = new Float32Array(COUNT * 3);
for (let i = 0; i < COUNT; i++) {
const dir = new THREE.Vector3(
Math.random() * 2 - 1,
Math.random() * 1.05 - 0.02,
Math.random() * 2 - 1
).normalize();
const r = 420 + Math.random() * 280;
positions[i * 3 + 0] = dir.x * r;
positions[i * 3 + 1] = dir.y * r;
positions[i * 3 + 2] = dir.z * r;
}
const geo = new THREE.BufferGeometry();
geo.setAttribute('position', new THREE.BufferAttribute(positions, 3));
const mat = new THREE.PointsMaterial({
size: 2.4,
map: glowTex,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
sizeAttenuation: false,
fog: false,
color: 0xcfe0ff,
opacity: 0.85
});
scene.add(new THREE.Points(geo, mat));
})();
/* =========================================================
4. 远处的星球 / 月亮
========================================================= */
(function buildMoon() {
const moonMat = new THREE.MeshBasicMaterial({ color: 0xd9c9ff, fog: false });
const moon = new THREE.Mesh(new THREE.SphereGeometry(16, 32, 24), moonMat);
moon.position.set(-118, 74, -300);
scene.add(moon);
const halo = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex,
color: 0x9c7bff,
transparent: true,
opacity: 0.55,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false
}));
halo.position.copy(moon.position);
halo.scale.set(160, 160, 1);
scene.add(halo);
})();
/* =========================================================
5. 紫色积云
========================================================= */
const clouds = [];
(function buildClouds() {
const palette = [
{ color: '#7a56d6', opacity: 0.42 },
{ color: '#5b3cae', opacity: 0.50 },
{ color: '#a98cff', opacity: 0.34 },
{ color: '#4a2f96', opacity: 0.55 },
{ color: '#c48ae0', opacity: 0.30 }
];
function createCloud(cfg) {
const group = new THREE.Group();
const puffs = cfg.puffs;
for (let i = 0; i < puffs; i++) {
const mat = new THREE.SpriteMaterial({
map: cloudTex,
color: new THREE.Color(cfg.color),
transparent: true,
opacity: cfg.opacity * (0.55 + Math.random() * 0.7),
depthWrite: false,
fog: true
});
const sprite = new THREE.Sprite(mat);
const a = Math.random() * Math.PI * 2;
const rad = Math.random() * cfg.spread;
sprite.position.set(
Math.cos(a) * rad,
(Math.random() - 0.28) * cfg.spread * 0.42,
Math.sin(a) * rad * 0.72
);
const s = cfg.size * (0.55 + Math.random() * 0.75);
sprite.scale.set(s, s * 0.62, 1);
group.add(sprite);
}
group.position.copy(cfg.pos);
group.userData.speed = cfg.speed;
return group;
}
for (let i = 0; i < 18; i++) {
const pal = palette[(Math.random() * palette.length) | 0];
const angle = Math.random() * Math.PI * 2;
const radius = 26 + Math.random() * 95;
const isLow = Math.random() < 0.4;
const cloud = createCloud({
color: pal.color,
opacity: pal.opacity,
puffs: 5 + ((Math.random() * 5) | 0),
spread: 1.0,
size: 1.0,
speed: 0.35 + Math.random() * 0.65,
pos: new THREE.Vector3(
Math.cos(angle) * radius,
isLow ? 4 + Math.random() * 6 : 12 + Math.random() * 30,
Math.sin(angle) * radius - 18
)
});
const overall = 3.6 + Math.random() * 7.5;
cloud.scale.setScalar(overall);
clouds.push(cloud);
scene.add(cloud);
}
})();
/* =========================================================
6. 反光海面(Reflector + 程序化波光层)
========================================================= */
const reflector = new Reflector(new THREE.PlaneGeometry(700, 700), {
clipBias: 0.004,
textureWidth: 1024,
textureHeight: 1024,
color: 0x8fa2cc
});
reflector.rotation.x = -Math.PI / 2;
reflector.position.y = 0;
scene.add(reflector);
// 波光层
const waveUniforms = {
uTime: { value: 0 },
uColor: { value: new THREE.Color('#9dc8ff') }
};
const waveMaterial = new THREE.ShaderMaterial({
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
uniforms: waveUniforms,
vertexShader: /* glsl */`
uniform float uTime;
varying vec2 vP;
void main() {
vec4 wp = modelMatrix * vec4(position, 1.0);
float w = sin(wp.x * 0.22 + uTime * 0.75) * 0.5
+ cos(wp.z * 0.27 - uTime * 0.55) * 0.5;
wp.y += w * 0.13;
vP = wp.xz;
gl_Position = projectionMatrix * viewMatrix * wp;
}
`,
fragmentShader: /* glsl */`
uniform float uTime;
uniform vec3 uColor;
varying vec2 vP;
void main() {
float a = sin(vP.x * 0.34 + uTime * 0.90);
float b = sin(vP.y * 0.27 - uTime * 0.68);
float c = sin((vP.x + vP.y) * 0.12 + uTime * 0.42);
float m = a * b + c * 0.5;
float crest = smoothstep(0.42, 1.10, m);
float d = length(vP - cameraPosition.xz);
float fade = exp(-d * 0.018) * 0.85 + 0.12;
vec3 col = uColor * crest * fade;
gl_FragColor = vec4(col, 1.0);
}
`
});
const waveMesh = new THREE.Mesh(new THREE.PlaneGeometry(700, 700, 96, 96), waveMaterial);
waveMesh.rotation.x = -Math.PI / 2;
waveMesh.position.y = 0.03;
scene.add(waveMesh);
/* =========================================================
7. 灯光
========================================================= */
scene.add(new THREE.HemisphereLight(0x6f82ff, 0x2a1044, 1.15));
const keyLight = new THREE.DirectionalLight(0xd9c6ff, 2.1);
keyLight.position.set(-7, 11, 9);
scene.add(keyLight);
const rimLight = new THREE.DirectionalLight(0x5fe6ff, 1.9);
rimLight.position.set(9, 5, -14);
scene.add(rimLight);
const pinkLight = new THREE.DirectionalLight(0xff8fc4, 0.65);
pinkLight.position.set(5, -4, -8);
scene.add(pinkLight);
/* =========================================================
8. Space Bunny
========================================================= */
function createBunny() {
const bunny = new THREE.Group(); // 原点位于脚底
const furMat = new THREE.MeshStandardMaterial({ color: 0xf7f1ea, roughness: 0.92, metalness: 0.0 });
const innerEarMat = new THREE.MeshStandardMaterial({
color: 0xefa8cd, roughness: 0.85,
emissive: 0x5c2340, emissiveIntensity: 0.35
});
const suitMat = new THREE.MeshStandardMaterial({ color: 0xdde6f5, roughness: 0.34, metalness: 0.38 });
const darkMat = new THREE.MeshStandardMaterial({ color: 0x1b2036, roughness: 0.38, metalness: 0.55 });
const visorMat = new THREE.MeshStandardMaterial({
color: 0x2b3d63, roughness: 0.18, metalness: 0.72,
emissive: 0x1b6f8a, emissiveIntensity: 0.55
});
const greenMat = new THREE.MeshStandardMaterial({
color: 0x2ec27e, roughness: 0.35, metalness: 0.35,
emissive: 0x0d5c38, emissiveIntensity: 0.6
});
const redMat = new THREE.MeshStandardMaterial({
color: 0xe8484c, roughness: 0.35, metalness: 0.35,
emissive: 0x6b1416, emissiveIntensity: 0.6
});
const glowCyan = new THREE.MeshBasicMaterial({ color: 0x9ff8ff });
const glowPink = new THREE.MeshBasicMaterial({ color: 0xff6ad5 });
/* --- 身体 --- */
const body = new THREE.Mesh(new THREE.SphereGeometry(0.5, 32, 24), furMat);
body.scale.set(0.95, 0.92, 0.86);
body.position.y = 0.60;
bunny.add(body);
/* --- 头 --- */
const head = new THREE.Mesh(new THREE.SphereGeometry(0.37, 32, 24), furMat);
head.position.set(0, 1.30, 0.05);
head.scale.set(1, 0.96, 0.93);
bunny.add(head);
/* --- 耳朵(带枢轴,方便晃动) --- */
const earPivots = [];
for (const side of [-1, 1]) {
const pivot = new THREE.Group();
pivot.position.set(side * 0.145, 1.56, 0.02);
pivot.rotation.z = side * 0.20;
pivot.rotation.x = -0.10;
const outer = new THREE.Mesh(new THREE.CapsuleGeometry(0.078, 0.46, 8, 16), furMat);
outer.position.y = 0.32;
outer.scale.set(1, 1, 0.55);
pivot.add(outer);
const inner = new THREE.Mesh(new THREE.CapsuleGeometry(0.046, 0.36, 8, 14), innerEarMat);
inner.position.set(0, 0.32, 0.028);
inner.scale.set(1, 1, 0.5);
pivot.add(inner);
bunny.add(pivot);
earPivots.push(pivot);
}
/* --- 眼睛 --- */
for (const side of [-1, 1]) {
const eye = new THREE.Mesh(
new THREE.SphereGeometry(0.074, 18, 14),
new THREE.MeshStandardMaterial({ color: 0x14101f, roughness: 0.18, metalness: 0.2 })
);
eye.position.set(side * 0.155, 1.335, 0.315);
bunny.add(eye);
const spark = new THREE.Mesh(new THREE.SphereGeometry(0.024, 8, 8), new THREE.MeshBasicMaterial({ color: 0xffffff }));
spark.position.set(side * 0.155 + 0.028, 1.362, 0.368);
bunny.add(spark);
}
/* --- 鼻子 --- */
const nose = new THREE.Mesh(
new THREE.SphereGeometry(0.032, 10, 8),
new THREE.MeshStandardMaterial({ color: 0xf5a8c4, roughness: 0.6, emissive: 0x5c2333, emissiveIntensity: 0.4 })
);
nose.position.set(0, 1.235, 0.355);
bunny.add(nose);
/* --- 酷酷的护目镜(推到额头上) --- */
const visor = new THREE.Mesh(new THREE.CapsuleGeometry(0.062, 0.30, 8, 16), visorMat);
visor.rotation.z = Math.PI / 2;
visor.position.set(0, 1.505, 0.245);
visor.scale.set(1, 1, 0.75);
bunny.add(visor);
const visorGlow = new THREE.Mesh(
new THREE.TorusGeometry(0.062, 0.008, 6, 18),
glowCyan
);
visorGlow.rotation.y = Math.PI / 2;
visorGlow.position.set(0.155, 1.505, 0.245);
bunny.add(visorGlow);
/* --- 天线 --- */
const antenna = new THREE.Mesh(new THREE.CylinderGeometry(0.011, 0.011, 0.32, 6), darkMat);
antenna.position.set(0.16, 1.74, -0.06);
antenna.rotation.z = -0.28;
bunny.add(antenna);
const antennaTip = new THREE.Mesh(new THREE.SphereGeometry(0.045, 12, 10), glowPink);
antennaTip.position.set(0.215, 1.90, -0.06);
bunny.add(antennaTip);
/* --- 喷气背包 --- */
const pack = new THREE.Mesh(new THREE.BoxGeometry(0.42, 0.44, 0.20), suitMat);
pack.position.set(0, 0.80, -0.40);
bunny.add(pack);
const packGlows = [];
for (const side of [-1, 1]) {
const nozzle = new THREE.Mesh(new THREE.CylinderGeometry(0.058, 0.075, 0.20, 12), darkMat);
nozzle.rotation.x = Math.PI / 2;
nozzle.position.set(side * 0.14, 0.60, -0.47);
bunny.add(nozzle);
const jet = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex,
color: 0x7fe9ff,
transparent: true,
opacity: 0.85,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false
}));
jet.position.set(side * 0.14, 0.60, -0.62);
jet.scale.set(0.42, 0.42, 1);
bunny.add(jet);
packGlows.push(jet);
}
/* --- 胸前控制面板 --- */
const panel = new THREE.Mesh(new THREE.BoxGeometry(0.30, 0.22, 0.07), darkMat);
panel.position.set(0, 0.72, 0.37);
bunny.add(panel);
const ledG = new THREE.Mesh(new THREE.SphereGeometry(0.028, 8, 8), glowCyan);
ledG.position.set(-0.075, 0.745, 0.412);
bunny.add(ledG);
const ledR = new THREE.Mesh(new THREE.SphereGeometry(0.028, 8, 8), glowPink);
ledR.position.set(0.075, 0.745, 0.412);
bunny.add(ledR);
/* --- 围巾 --- */
const scarfRing = new THREE.Mesh(
new THREE.TorusGeometry(0.255, 0.058, 8, 24),
new THREE.MeshStandardMaterial({ color: 0xe0433f, roughness: 0.85, emissive: 0x4a1010, emissiveIntensity: 0.4 })
);
scarfRing.rotation.x = Math.PI / 2;
scarfRing.position.set(0, 1.02, 0.02);
bunny.add(scarfRing);
const scarfGeo = new THREE.PlaneGeometry(0.15, 0.9, 2, 10);
scarfGeo.translate(0, -0.45, 0);
const scarfTail = new THREE.Mesh(scarfGeo, new THREE.MeshStandardMaterial({
color: 0xe0433f, roughness: 0.85, side: THREE.DoubleSide,
emissive: 0x4a1010, emissiveIntensity: 0.35
}));
scarfTail.position.set(-0.20, 1.05, -0.22);
scarfTail.rotation.set(0.25, 0.3, 0.15);
bunny.add(scarfTail);
const scarfBase = Float32Array.from(scarfGeo.attributes.position.array);
/* --- 爪子 --- */
for (const side of [-1, 1]) {
const paw = new THREE.Mesh(new THREE.SphereGeometry(0.115, 16, 12), furMat);
paw.scale.set(0.82, 0.9, 1.15);
paw.position.set(side * 0.33, 0.50, 0.20);
bunny.add(paw);
}
/* --- 脚 --- */
for (const side of [-1, 1]) {
const foot = new THREE.Mesh(new THREE.SphereGeometry(0.17, 16, 12), furMat);
foot.scale.set(0.85, 0.58, 1.5);
foot.position.set(side * 0.24, 0.095, 0.14);
bunny.add(foot);
}
/* --- 尾巴 --- */
const tail = new THREE.Mesh(new THREE.SphereGeometry(0.15, 16, 12),
new THREE.MeshStandardMaterial({ color: 0xfffaf4, roughness: 0.95 }));
tail.position.set(0, 0.50, -0.42);
bunny.add(tail);
return { group: bunny, earPivots, scarfTail, scarfBase, packGlows };
}
const bunnyRig = createBunny();
const bunny = bunnyRig.group;
/* 浮空平台 */
const platformGroup = new THREE.Group();
platformGroup.position.set(0.9, 0, 1.25);
scene.add(platformGroup);
const platformMat = new THREE.MeshStandardMaterial({
color: 0x2b2340, roughness: 0.9, metalness: 0.15
});
const platform = new THREE.Mesh(new THREE.CylinderGeometry(1.45, 1.05, 0.62, 28, 1), platformMat);
platform.position.y = -0.02;
platformGroup.add(platform);
const platformRim = new THREE.Mesh(
new THREE.TorusGeometry(1.44, 0.026, 6, 48),
new THREE.MeshBasicMaterial({ color: 0x63e8ff, transparent: true, opacity: 0.85 })
);
platformRim.rotation.x = Math.PI / 2;
platformRim.position.y = 0.29;
platformGroup.add(platformRim);
const platformGlow = new THREE.PointLight(0x63e8ff, 8, 6, 2);
platformGlow.position.set(0, 0.5, 0);
platformGroup.add(platformGlow);
// 兔子站在平台顶面
bunny.scale.setScalar(1.15);
bunny.position.set(0, 0.30, 0);
bunny.rotation.y = -0.35;
platformGroup.add(bunny);
/* =========================================================
9. 未来战斗机(白 / 绿 / 红 涂装)
========================================================= */
function createJet() {
const jet = new THREE.Group(); // 用于位置与朝向
const model = new THREE.Group(); // 模型自身机头朝 -Z
model.rotation.y = Math.PI; // 让机头在父空间中指向 +Z(配合 lookAt)
jet.add(model);
const whiteMat = new THREE.MeshStandardMaterial({ color: 0xeef3f9, roughness: 0.30, metalness: 0.48 });
const greenMat = new THREE.MeshStandardMaterial({
color: 0x1fa860, roughness: 0.34, metalness: 0.40,
emissive: 0x0a3a22, emissiveIntensity: 0.45
});
const redMat = new THREE.MeshStandardMaterial({
color: 0xd93a3e, roughness: 0.34, metalness: 0.40,
emissive: 0x5a1416, emissiveIntensity: 0.45
});
const darkMat = new THREE.MeshStandardMaterial({ color: 0x2b3145, roughness: 0.42, metalness: 0.72 });
const glassMat = new THREE.MeshStandardMaterial({
color: 0x8fe9ff, transparent: true, opacity: 0.42,
roughness: 0.06, metalness: 0.25,
emissive: 0x0e3d52, emissiveIntensity: 0.9,
side: THREE.DoubleSide
});
/* 机身 */
const fusGeo = new THREE.CapsuleGeometry(0.42, 2.0, 12, 24);
fusGeo.rotateX(Math.PI / 2);
model.add(new THREE.Mesh(fusGeo, whiteMat));
/* 机头锥 */
const noseGeo = new THREE.ConeGeometry(0.42, 1.1, 24, 1, true);
noseGeo.rotateX(-Math.PI / 2);
const nose = new THREE.Mesh(noseGeo, whiteMat);
nose.position.z = -1.85;
model.add(nose);
/* 座舱盖 */
const canGeo = new THREE.SphereGeometry(0.30, 24, 16, 0, Math.PI * 2, 0, Math.PI * 0.5);
canGeo.scale(1.0, 0.85, 2.1);
const canopy = new THREE.Mesh(canGeo, glassMat);
canopy.position.set(0, 0.30, -0.55);
model.add(canopy);
/* 主翼 */
const wingShape = new THREE.Shape();
wingShape.moveTo(0.18, -0.85);
wingShape.lineTo(2.35, 0.05);
wingShape.lineTo(2.35, 0.45);
wingShape.lineTo(0.18, 1.25);
wingShape.closePath();
const wingGeo = new THREE.ExtrudeGeometry(wingShape, {
depth: 0.10, bevelEnabled: true,
bevelSize: 0.025, bevelThickness: 0.025, bevelSegments: 2
});
wingGeo.rotateX(Math.PI / 2);
wingGeo.translate(0, 0.05, 0);
const wingR = new THREE.Mesh(wingGeo, whiteMat);
model.add(wingR);
const wingL = new THREE.Mesh(wingGeo, whiteMat);
wingL.scale.x = -1;
model.add(wingL);
/* 翼尖航行灯 */
const tipR = new THREE.Mesh(new THREE.SphereGeometry(0.062, 12, 10),
new THREE.MeshBasicMaterial({ color: 0x45ffa2 }));
tipR.position.set(2.30, 0.06, 0.24);
model.add(tipR);
const tipL = new THREE.Mesh(new THREE.SphereGeometry(0.062, 12, 10),
new THREE.MeshBasicMaterial({ color: 0xff4d55 }));
tipL.position.set(-2.30, 0.06, 0.24);
model.add(tipL);
/* 垂尾 */
const finShape = new THREE.Shape();
finShape.moveTo(-0.45, 0);
finShape.lineTo(0.50, 0);
finShape.lineTo(0.42, 0.95);
finShape.lineTo(-0.05, 0.95);
finShape.closePath();
const finGeo = new THREE.ExtrudeGeometry(finShape, { depth: 0.08, bevelEnabled: false });
finGeo.rotateY(-Math.PI / 2);
finGeo.translate(0.04, 0, 0);
const fin = new THREE.Mesh(finGeo, whiteMat);
fin.position.set(0, 0.26, 0.85);
model.add(fin);
/* 水平尾翼 */
const stabShape = new THREE.Shape();
stabShape.moveTo(0.12, -0.36);
stabShape.lineTo(1.05, 0.02);
stabShape.lineTo(1.05, 0.24);
stabShape.lineTo(0.12, 0.56);
stabShape.closePath();
const stabGeo = new THREE.ExtrudeGeometry(stabShape, { depth: 0.07, bevelEnabled: false });
stabGeo.rotateX(Math.PI / 2);
const stabR = new THREE.Mesh(stabGeo, whiteMat);
stabR.position.set(0, 0.02, 1.05);
model.add(stabR);
const stabL = new THREE.Mesh(stabGeo, whiteMat);
stabL.scale.x = -1;
stabL.position.set(0, 0.02, 1.05);
model.add(stabL);
/* 涂装环带 */
const bandGreen = new THREE.Mesh(
new THREE.CylinderGeometry(0.435, 0.435, 0.30, 28, 1, true), greenMat);
bandGreen.rotation.x = Math.PI / 2;
bandGreen.position.z = -0.72;
model.add(bandGreen);
const bandRed = new THREE.Mesh(
new THREE.CylinderGeometry(0.435, 0.435, 0.30, 28, 1, true), redMat);
bandRed.rotation.x = Math.PI / 2;
bandRed.position.z = -0.30;
model.add(bandRed);
/* 尾喷口 */
const nozzle = new THREE.Mesh(
new THREE.CylinderGeometry(0.30, 0.36, 0.55, 24, 1, true), darkMat);
nozzle.rotation.x = Math.PI / 2;
nozzle.position.z = 1.55;
model.add(nozzle);
/* 尾焰 */
const flameMat = new THREE.MeshBasicMaterial({
color: 0x8fe8ff, transparent: true, opacity: 0.7,
blending: THREE.AdditiveBlending, depthWrite: false, side: THREE.DoubleSide
});
const flameGeo = new THREE.ConeGeometry(0.26, 1.2, 20, 1, true);
flameGeo.rotateX(Math.PI / 2);
flameGeo.translate(0, 0, 0.6);
const flame = new THREE.Mesh(flameGeo, flameMat);
flame.position.z = 1.85;
model.add(flame);
const engineLight = new THREE.PointLight(0x66e6ff, 26, 16, 2);
engineLight.position.set(0, 0, 2.2);
model.add(engineLight);
/* 机头灯 */
const noseLight = new THREE.Mesh(new THREE.SphereGeometry(0.05, 10, 8),
new THREE.MeshBasicMaterial({ color: 0xfff3c4 }));
noseLight.position.set(0, 0, -2.36);
model.add(noseLight);
return { group: jet, flame, model };
}
const jetRig = createJet();
const jet = jetRig.group;
scene.add(jet);
/* =========================================================
10. “just breathe” 发光文字
========================================================= */
const textGroup = new THREE.Group();
textGroup.position.set(0, 4.7, -11.5);
scene.add(textGroup);
const textTex = makeTextTexture('just breathe');
const textMesh = new THREE.Mesh(
new THREE.PlaneGeometry(8.4, 2.1),
new THREE.MeshBasicMaterial({
map: textTex,
transparent: true,
blending: THREE.AdditiveBlending,
depthWrite: false,
depthTest: true,
fog: false,
opacity: 0.95,
toneMapped: false
})
);
textGroup.add(textMesh);
const textHalo = new THREE.Sprite(new THREE.SpriteMaterial({
map: glowTex,
color: 0x7ec8ff,
transparent: true,
opacity: 0.35,
blending: THREE.AdditiveBlending,
depthWrite: false,
fog: false
}));
textHalo.scale.set(13, 4.5, 1);
textHalo.position.z = -0.6;
textGroup.add(textHalo);
/* =========================================================
11. 后处理
========================================================= */
const composer = new EffectComposer(renderer);
composer.setPixelRatio(Math.min(window.devicePixelRatio, 2));
composer.setSize(window.innerWidth, window.innerHeight);
composer.addPass(new RenderPass(scene, camera));
const bloomPass = new UnrealBloomPass(
new THREE.Vector2(window.innerWidth, window.innerHeight),
0.85, // strength
0.70, // radius
0.72 // threshold
);
composer.addPass(bloomPass);
composer.addPass(new OutputPass());
/* =========================================================
12. 动画更新
========================================================= */
const lookTarget = new THREE.Vector3(0, 1.7, -2.6);
let lastBunnyY = 0;
let earLag = 0;
function updateCamera() {
const t = time;
camera.position.set(
0.8 + Math.sin(t * 0.062) * 2.6,
2.15 + Math.sin(t * 0.091 + 0.7) * 0.38,
9.2 + Math.cos(t * 0.045) * 1.4
);
lookTarget.set(
Math.sin(t * 0.071) * 0.9,
1.72 + Math.sin(t * 0.11 + 1.3) * 0.26,
-2.6 + Math.cos(t * 0.053) * 1.2
);
camera.lookAt(lookTarget);
}
function updateBunny(dt) {
const P = 1.75;
const c = (time % P) / P;
let y = 0, squash = 1, tilt = 0;
if (c < 0.30) {
// 蓄力下蹲
const k = c / 0.30;
const s = Math.sin(k * Math.PI);
y = -0.09 * s;
squash = 1 - 0.16 * s;
tilt = 0.10 * s;
} else if (c < 0.78) {
// 腾空
const k = (c - 0.30) / 0.48;
const s = Math.sin(k * Math.PI);
y = s * 0.85;
squash = 1 + 0.14 * s;
tilt = -0.28 * s;
} else {
// 落地缓冲
const k = (c - 0.78) / 0.22;
const s = Math.sin(k * Math.PI);
y = 0;
squash = 1 - 0.22 * s;
tilt = 0.14 * s;
}
const vel = (y - lastBunnyY) / Math.max(dt, 0.001);
lastBunnyY = y;
earLag += (-vel * 0.16 - earLag) * Math.min(1, dt * 8);
bunny.position.y = 0.30 + y;
bunny.rotation.x = tilt;
const sxz = 1 / Math.sqrt(squash);
bunny.scale.set(1.15 * sxz, 1.15 * squash, 1.15 * sxz);
// 耳朵晃动
bunnyRig.earPivots.forEach((pivot, i) => {
const phase = i * 1.1;
const flutter = Math.sin(time * 3.1 + phase) * 0.07;
const bounce = Math.sin(time * 6.2 + phase) * 0.035 * Math.abs(vel);
pivot.rotation.x = -0.10 + earLag * 0.9 + flutter + bounce;
});
// 围巾飘动
const pos = bunnyRig.scarfTail.geometry.attributes.position;
const base = bunnyRig.scarfBase;
for (let i = 0; i < pos.count; i++) {
const bx = base[i * 3];
const by = base[i * 3 + 1];
const bz = base[i * 3 + 2];
const k = Math.min(1, Math.abs(by) / 0.9);
pos.setX(i, bx + Math.sin(time * 2.6 + k * 4.2) * 0.09 * k - vel * 0.05 * k);
pos.setZ(i, bz + Math.sin(time * 2.0 + k * 3.1) * 0.11 * k - k * 0.12 - vel * 0.4 * k);
}
pos.needsUpdate = true;
// 背包喷口的呼吸感
bunnyRig.packGlows.forEach((g, i) => {
const s = 0.34 + Math.abs(Math.sin(time * 5 + i * 1.7)) * 0.18;
g.scale.set(s, s, 1);
});
}
const jetLookAt = new THREE.Vector3();
function updateJet(dt) {
const u = time * 0.075;
const R = 12.0;
const cx = Math.sin(u) * R;
const cz = -14 + Math.cos(u) * R * 0.85;
const cy = 6.6 + Math.sin(u * 2.3) * 1.15;
jet.position.set(cx, cy, cz);
// 切向速度方向
const vx = Math.cos(u) * R;
const vz = -Math.sin(u) * R * 0.85;
jetLookAt.set(cx + vx, cy + Math.sin(u * 2.3 + 0.4) * 0.35, cz + vz);
jet.lookAt(jetLookAt);
// 侧倾(转弯 banking)
jet.rotateZ(0.22 + Math.sin(time * 0.4) * 0.05);
// 尾焰脉动
const pulse = 0.85 + Math.sin(time * 12) * 0.12 + Math.sin(time * 5.3) * 0.06;
jetRig.flame.scale.set(0.9, 0.9 * pulse, pulse);
jetRig.flame.material.opacity = 0.55 + Math.sin(time * 9) * 0.12;
}
function updateClouds(dt) {
for (const c of clouds) {
c.position.x += c.userData.speed * dt * 0.45;
if (c.position.x > 130) c.position.x = -130;
c.rotation.y += dt * 0.02;
}
}
/* =========================================================
13. 主循环
========================================================= */
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
time += dt;
updateCamera();
updateBunny(dt);
updateJet(dt);
updateClouds(dt);
// 海面波动
waveUniforms.uTime.value = time;
// 平台轻微浮动
platformGroup.position.y = Math.sin(time * 0.72) * 0.045;
// 文字始终面向镜头 + 缓慢呼吸漂浮
textGroup.quaternion.copy(camera.quaternion);
textGroup.position.y = 4.7 + Math.sin(time * 0.55) * 0.16;
textGroup.position.x = Math.sin(time * 0.31) * 0.35;
textMesh.material.opacity = 0.82 + Math.sin(time * 1.1) * 0.13;
composer.render();
}
/* =========================================================
14. 响应式
========================================================= */
function onResize() {
const w = window.innerWidth;
const h = window.innerHeight;
camera.aspect = w / h;
camera.updateProjectionMatrix();
renderer.setSize(w, h);
composer.setSize(w, h);
bloomPass.resolution.set(w, h);
}
window.addEventListener('resize', onResize);
/* =========================================================
15. 启动
========================================================= */
animate();
setTimeout(() => {
const el = document.getElementById('loading');
if (el) el.classList.add('hide');
}, 600);
</script>
</body>
</html>
梦幻太空海岸的视觉与叙事解析
画面把深蓝到紫色的渐变天空、体积感紫色积云、镜面海面和未来战斗机拢在一起,镜头缓慢环绕。
- 主角与动作:Space Bunny 有长耳朵、圆润身体、护目镜和喷气背包,蹦跳时带出下蹲、腾空、落地的弹性,耳朵和围巾也会跟着晃动。
- 场景元素:战斗机沿空中轨迹巡航,海面用 Reflector 做实时倒影,波光层添了细微波动,云朵缓慢漂移。
- 镜头与文字:镜头持续低角度缓慢环绕,发光文字“just breathe”始终面向观众,像漂浮在天空中的温柔提醒。
优化建议: 如果想调整整体氛围,可以修改 animate 函数中 uTime 的系数来改变海面波动速度;记得在浏览器控制台查看加载状态,确保 CDN 资源可用。
Timing details
- Actual wait (client)
- 0.71s
- Client preparation
- 0.04s
- Proxy preparation
- 0.00s
- Upstream response headers
- 0.34s
- Upstream first byte
- 0.34s
- Upstream first token
- 0.46s
- Upstream retries
- 0
Actual wait uses the client clock. Upstream timings start after proxy preparation and are cumulative; do not add these values. Upstream delay includes network, scheduling, processing and buffering; it does not prove provider queueing.
Diagnostic receipt
{
"attempts": 1,
"startedAt": "2026-09-29T03:43:55.799Z",
"transport": "railway",
"ttftSource": "proxy",
"clientTtftMs": 710.5,
"completionId": "752b3d6b-b846-42f5-aa6c-8b61ae2a0c2e",
"streamResumes": 4,
"proxyPrepareMs": 0,
"proxyRequestId": "HE-CGXgzQ-e_z4zHpHNmDw",
"upstreamStatus": 200,
"upstreamTtftMs": 464,
"clientHeadersMs": 251,
"clientPrepareMs": 44.40000009536743,
"upstreamHeadersMs": 335,
"upstreamFirstByteMs": 336,
"clientFirstContentMs": 195086.10000038147
}我来完成这个作品。我的构思是:把「梦境海岸」做成一个真正有叙事闭环的小世界——白色小战机沿着海岸低空往返巡飞,兔子在浮台、礁石与机翼之间循环蹦跳,等战机掠过头顶时一跃登机、搭飞一段再跳回岸边;月光在海面拉出一条碎金光带,"just breathe" 做成一座漂浮在海上的发光信标,远处天空还有一行呼吸般明灭的同款文字。镜头是自动的电影化运镜(五组机位循环:入梦 / 低语海面 / 翼上同行 / 掠海 / 浮台特写),点击画面可以切换。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Space Bunny · 梦境海岸</title>
<style>
html,body{margin:0;padding:0;height:100%;overflow:hidden;background:#05030f;}
canvas{display:block;}
/* ---------- 电影暗角与颗粒 ---------- */
#vig{position:fixed;inset:0;pointer-events:none;z-index:5;
background:radial-gradient(ellipse at 50% 42%, transparent 52%, rgba(4,2,16,.62) 100%);}
#grain{position:fixed;inset:0;pointer-events:none;z-index:6;opacity:.05;
background-image:url('data:image/svg+xml;utf8,<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140"><filter id="n"><feTurbulence type="fractalNoise" baseFrequency="0.85" numOctaves="2"/></filter><rect width="140" height="140" filter="url(%23n)" opacity="0.55"/></svg>');}
/* ---------- 常驻 UI ---------- */
.serif{font-family:Georgia,'Times New Roman','Songti SC',serif;}
#brand{position:fixed;left:30px;bottom:26px;z-index:10;color:#d8cdf2;pointer-events:none;user-select:none;}
#brand h1{margin:0;font-size:15px;font-weight:600;letter-spacing:.5em;text-shadow:0 0 18px rgba(140,110,220,.6);}
#brand p{margin:6px 0 0;font-size:11px;letter-spacing:.22em;opacity:.62;font-style:italic;}
#hint{position:fixed;right:30px;bottom:28px;z-index:10;color:#cfc3ee;font-size:11px;
letter-spacing:.3em;pointer-events:none;user-select:none;animation:dim 2s ease 9s forwards;}
@keyframes dim{to{opacity:.4}}
#shotname{position:fixed;left:30px;bottom:86px;z-index:10;color:#efe9ff;font-size:12px;
letter-spacing:.55em;opacity:0;pointer-events:none;}
#shotname.show{animation:shotIn 3.4s ease forwards;}
@keyframes shotIn{0%{opacity:0;transform:translateY(10px)}14%{opacity:.92;transform:none}68%{opacity:.92}100%{opacity:0}}
/* ---------- 开场 ---------- */
#fade{position:fixed;inset:0;background:#04020d;z-index:20;pointer-events:none;
animation:fadeAway 3.2s ease .4s forwards;}
@keyframes fadeAway{to{opacity:0;visibility:hidden}}
#title{position:fixed;inset:0;display:flex;flex-direction:column;justify-content:center;align-items:center;
z-index:21;pointer-events:none;opacity:0;color:#eae2ff;text-align:center;
animation:titleSeq 10s ease 1.6s forwards;}
#title h1{margin:0;font-size:clamp(30px,5.4vw,58px);font-weight:600;letter-spacing:.55em;text-indent:.55em;
text-shadow:0 0 34px rgba(150,120,255,.75),0 0 90px rgba(120,90,220,.4);}
#title p{margin:22px 0 0;font-size:clamp(12px,1.4vw,16px);letter-spacing:.4em;font-style:italic;opacity:.8;
color:#bfeee2;text-shadow:0 0 22px rgba(110,240,215,.5);}
@keyframes titleSeq{0%{opacity:0;transform:translateY(14px)}18%{opacity:1;transform:none}74%{opacity:1}100%{opacity:0;visibility:hidden}}
</style>
</head>
<body>
<div id="vig"></div>
<div id="grain"></div>
<div id="brand" class="serif"><h1>SPACE BUNNY</h1><p>梦境海岸 · The Dreaming Shore</p></div>
<div id="hint" class="serif">轻点画面 · 切换镜头</div>
<div id="shotname" class="serif"></div>
<div id="fade"></div>
<div id="title" class="serif"><h1>SPACE BUNNY</h1><p>—— 梦境海岸 · just breathe ——</p></div>
<script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script>
<script>
'use strict';
/* ============================================================
SPACE BUNNY · 梦境海岸
天空 / 海面 / 积云 / 未来战机 / 太空兔 / 发光信标 / 电影运镜
============================================================ */
/* ---------------- 基础 ---------------- */
const renderer = new THREE.WebGLRenderer({antialias:true});
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
document.body.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x1a0f33, 0.0075);
const camera = new THREE.PerspectiveCamera(46, innerWidth/innerHeight, 0.1, 700);
camera.position.set(-28, 9.5, 34);
/* 程序化环境立方体贴图:给金属件一点紫夜的环境反射 */
function gradFace(top, bottom){
const c = document.createElement('canvas'); c.width = c.height = 32;
const x = c.getContext('2d');
const g = x.createLinearGradient(0,0,0,32);
g.addColorStop(0, top); g.addColorStop(1, bottom);
x.fillStyle = g; x.fillRect(0,0,32,32);
return c;
}
const envTex = new THREE.CubeTexture([
gradFace('#241a4a','#0d0a24'), gradFace('#241a4a','#0d0a24'),
gradFace('#7a58b2','#241a4a'), gradFace('#0a0620','#140b30'),
gradFace('#3a2a6a','#0d0a24'), gradFace('#3a2a6a','#0d0a24')
]);
envTex.needsUpdate = true;
scene.environment = envTex;
/* ---------------- 灯光 ---------------- */
scene.add(new THREE.AmbientLight(0x241a3e, 0.6));
scene.add(new THREE.HemisphereLight(0x8a6ad0, 0x1a0f33, 0.5));
const moonDir = new THREE.Vector3(-0.35, 0.34, -1).normalize();
const moonLight = new THREE.DirectionalLight(0xf0e0c0, 0.9);
moonLight.position.copy(moonDir).multiplyScalar(70);
moonLight.castShadow = true;
moonLight.shadow.mapSize.set(2048, 2048);
moonLight.shadow.bias = -0.0005;
const sc = moonLight.shadow.camera;
sc.left = -38; sc.right = 38; sc.top = 38; sc.bottom = -38; sc.near = 10; sc.far = 170;
scene.add(moonLight);
/* ---------------- 小工具 ---------------- */
const V3 = (x,y,z)=>new THREE.Vector3(x,y,z);
const clamp = THREE.MathUtils.clamp;
const rand = (a,b)=>a + Math.random()*(b-a);
function lerpAngle(a,b,k){
let d = ((b - a + Math.PI) % (Math.PI*2) + Math.PI*2) % (Math.PI*2) - Math.PI;
return a + d*k;
}
/* 柔光贴图(用于光晕 sprite)*/
function glowTexture(inner, outer){
const c = document.createElement('canvas'); c.width = c.height = 128;
const x = c.getContext('2d');
const g = x.createRadialGradient(64,64,0,64,64,64);
g.addColorStop(0, inner); g.addColorStop(0.35, outer); g.addColorStop(1, 'rgba(0,0,0,0)');
x.fillStyle = g; x.fillRect(0,0,128,128);
return new THREE.CanvasTexture(c);
}
const softGlow = glowTexture('rgba(255,255,255,1)','rgba(255,255,255,0.28)');
function makeGlowSprite(color, w, h, opacity){
const m = new THREE.SpriteMaterial({map:softGlow, color:color, transparent:true,
blending:THREE.AdditiveBlending, depthWrite:false, opacity:opacity});
const s = new THREE.Sprite(m); s.scale.set(w, h, 1); return s;
}
/* 发光文字纹理(逐字符加字距 + 多层柔光)*/
function makeTextTexture(withChrome){
const c = document.createElement('canvas'); c.width = 1024; c.height = 320;
const g = c.getContext('2d');
const text = 'just breathe';
g.font = 'italic 600 128px Georgia, "Times New Roman", serif';
g.textBaseline = 'middle'; g.textAlign = 'center';
const ls = 10;
const widths = [...text].map(ch => g.measureText(ch).width);
const total = widths.reduce((a,b)=>a+b,0) + ls*(text.length-1);
function pass(blur, fill){
g.shadowColor = 'rgba(126,240,220,0.85)'; g.shadowBlur = blur; g.fillStyle = fill;
let x = (1024-total)/2;
for(let i=0;i<text.length;i++){ g.fillText(text[i], x+widths[i]/2, 148); x += widths[i]+ls; }
}
if(withChrome){
pass(42,'rgba(126,240,220,0.26)'); pass(20,'rgba(126,240,220,0.5)');
pass(8,'rgba(180,255,240,0.85)'); pass(0,'rgba(255,255,255,0.98)');
g.shadowBlur = 0;
g.strokeStyle = 'rgba(160,244,226,0.55)'; g.lineWidth = 2;
g.beginPath(); g.moveTo(180,256); g.lineTo(844,256); g.stroke();
g.beginPath(); g.moveTo(236,34); g.lineTo(788,34); g.stroke();
const dia = (cx,cy,r)=>{g.beginPath();g.moveTo(cx,cy-r);g.lineTo(cx+r,cy);g.lineTo(cx,cy+r);g.lineTo(cx-r,cy);g.closePath();g.fill();};
g.fillStyle = 'rgba(160,244,226,0.8)';
dia(160,256,5); dia(864,256,5); dia(216,34,4); dia(808,34,4);
g.font = '22px Georgia, serif'; g.textAlign = 'center'; g.fillStyle = 'rgba(190,236,226,0.55)';
g.fillText('L U N A · 7 D E E P - S H O R E B E A C O N', 512, 292);
}else{
pass(30,'rgba(200,235,255,0.55)'); pass(10,'rgba(235,245,255,0.8)');
}
return new THREE.CanvasTexture(c);
}
/* ---------------- 天空 ---------------- */
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide, depthWrite: false, fog: false,
uniforms: { uTime:{value:0}, uMoonDir:{value:moonDir} },
vertexShader: `
varying vec3 vDir;
void main(){ vDir = position; gl_Position = projectionMatrix*modelViewMatrix*vec4(position,1.0); }`,
fragmentShader: `
uniform float uTime; uniform vec3 uMoonDir; varying vec3 vDir;
float hash21(vec2 p){ p=fract(p*vec2(127.1,311.7)); p+=dot(p,p+34.5); return fract(p.x*p.y); }
void main(){
vec3 d = normalize(vDir);
float h = d.y;
/* 深蓝 → 紫 → 海平线暖紫 */
vec3 col = mix(vec3(0.40,0.20,0.44), vec3(0.15,0.07,0.30), smoothstep(-0.02,0.42,h));
col = mix(col, vec3(0.015,0.03,0.10), smoothstep(0.30,0.85,h));
col = mix(vec3(0.10,0.05,0.18), col, smoothstep(-0.25,0.0,h));
/* 一抹斜向银河 */
float band = exp(-pow(dot(d, normalize(vec3(0.55,0.30,-0.75))),2.0)*7.0);
col += vec3(0.09,0.05,0.14)*band*smoothstep(-0.05,0.3,h);
/* 星星(网格 hash + 闪烁)*/
vec2 sp = vec2(atan(d.z,d.x)*10.0, d.y*36.0);
vec2 cell = floor(sp); vec2 f = fract(sp)-0.5;
float rnd = hash21(cell);
vec2 off = vec2(hash21(cell+7.3), hash21(cell+3.1))-0.5;
float star = smoothstep(0.10,0.0,length(f-off*0.7))*step(0.82,rnd);
star *= 0.5+0.5*sin(uTime*(1.0+rnd*4.0)+rnd*40.0);
col += vec3(0.9,0.92,1.0)*star*smoothstep(0.02,0.25,h)*0.9;
/* 梦境大月亮 */
float m = dot(d, uMoonDir);
float disk = smoothstep(0.99955,0.99985,m);
vec2 mp = vec2(atan(d.x,d.z)*40.0, d.y*40.0);
float mn = hash21(floor(mp));
vec3 moonCol = vec3(0.98,0.90,0.72)*mix(1.0,0.86,step(0.72,mn)*0.6);
col = mix(col, moonCol, disk);
col += vec3(0.45,0.34,0.30)*pow(max(m,0.0),180.0)*0.55;
gl_FragColor = vec4(col,1.0);
}`
});
const sky = new THREE.Mesh(new THREE.SphereGeometry(300, 48, 32), skyMat);
sky.renderOrder = -1;
scene.add(sky);
/* ---------------- 海面 ---------------- */
const seaMat = new THREE.ShaderMaterial({
fog: false,
uniforms: { uTime:{value:0}, uMoonDir:{value:moonDir} },
vertexShader: `
uniform float uTime; varying vec3 vWorldPos;
void main(){
vec3 pos = position;
vec2 wp = (modelMatrix*vec4(position,1.0)).xz;
pos.z += sin(wp.x*0.10 + uTime*0.7)*0.45 + sin(dot(wp,vec2(0.07,0.09)) - uTime*0.5)*0.40;
vec4 world = modelMatrix*vec4(pos,1.0);
vWorldPos = world.xyz;
gl_Position = projectionMatrix*viewMatrix*world;
}`,
fragmentShader: `
uniform float uTime; uniform vec3 uMoonDir; varying vec3 vWorldPos;
float hash21(vec2 p){ p=fract(p*vec2(127.1,311.7)); p+=dot(p,p+34.5); return fract(p.x*p.y); }
float waveH(vec2 p, float t, float att){
float h = sin(p.x*0.10 + t*0.7)*0.45 + sin(dot(p,vec2(0.07,0.09)) - t*0.5)*0.40;
h += att*( sin(dot(p,vec2(0.8,0.6)) + t*1.1)*0.22
+ sin(dot(p,vec2(-0.6,0.8))*1.7 - t*1.6)*0.15
+ sin(dot(p,vec2(0.25,-0.95))*3.1 + t*2.3)*0.09 );
return h;
}
vec3 skyGrad(vec3 d){
float h = max(d.y, 0.0);
vec3 c = mix(vec3(0.40,0.20,0.44), vec3(0.15,0.07,0.30), smoothstep(0.0,0.45,h));
return mix(c, vec3(0.015,0.03,0.10), smoothstep(0.30,0.9,h));
}
void main(){
vec2 p = vWorldPos.xz; float t = uTime;
float att = exp(-length(p)*0.012);
float e = 0.35;
float hC = waveH(p,t,att), hX = waveH(p+vec2(e,0.0),t,att), hZ = waveH(p+vec2(0.0,e),t,att);
vec3 n = normalize(vec3(-(hX-hC)/e*0.55, 1.0, -(hZ-hC)/e*0.55));
vec3 V = normalize(cameraPosition - vWorldPos);
vec3 R = reflect(-V, n); R.y = abs(R.y)*0.6 + 0.02;
vec3 sky = skyGrad(normalize(R));
float fres = pow(1.0 - max(dot(n,V),0.0), 3.0);
vec3 col = mix(vec3(0.05,0.025,0.13), sky, 0.28 + 0.72*fres);
/* 云影斑驳 */
float cl = sin(p.x*0.10+1.7)*sin(p.y*0.12-0.6) + sin(dot(p,vec2(0.05,0.08))*2.0 + t*0.15);
col += vec3(0.08,0.045,0.13)*max(cl,0.0)*0.35;
/* 月光高光带 */
vec3 H = normalize(uMoonDir + V);
float doth = max(dot(n,H),0.0);
col += vec3(1.0,0.87,0.62)*(pow(doth,420.0)*1.6 + pow(doth,18.0)*0.20);
/* 碎金闪点 */
vec2 scell = floor(p*3.0);
float sr = hash21(scell + vec2(floor(t*2.0)*17.3, floor(t*2.0)*29.1));
col += vec3(1.0,0.92,0.72)*step(0.993,sr)*pow(doth,30.0)*1.2;
/* 与雾色衔接 */
float d = distance(cameraPosition, vWorldPos);
float fg = 1.0 - exp(-pow(d*0.0075, 2.0));
col = mix(col, vec3(0.102,0.059,0.20), fg);
gl_FragColor = vec4(col, 1.0);
}`
});
const sea = new THREE.Mesh(new THREE.PlaneGeometry(360, 360, 110, 110), seaMat);
sea.rotation.x = -Math.PI/2;
scene.add(sea);
/* ---------------- 材质库 ---------------- */
const M = {
white: new THREE.MeshStandardMaterial({color:0xf2f4f5, metalness:0.35, roughness:0.35}),
green: new THREE.MeshStandardMaterial({color:0x2fbf71, metalness:0.3, roughness:0.4}),
red: new THREE.MeshStandardMaterial({color:0xe8433f, metalness:0.3, roughness:0.4}),
dark: new THREE.MeshStandardMaterial({color:0x232030, metalness:0.6, roughness:0.5}),
glass: new THREE.MeshStandardMaterial({color:0x0e2a3a, metalness:0.9, roughness:0.12, emissive:0x0a3038, emissiveIntensity:0.7}),
fur: new THREE.MeshStandardMaterial({color:0xf7f0e8, metalness:0, roughness:0.95}),
pink: new THREE.MeshStandardMaterial({color:0xf0a2b4, metalness:0, roughness:0.9}),
darkEye: new THREE.MeshStandardMaterial({color:0x1c1826, metalness:0.2, roughness:0.35}),
cloud: new THREE.MeshStandardMaterial({color:0xd8c8f0, metalness:0, roughness:1, emissive:0x2a1850, emissiveIntensity:0.65}),
cloudShell: new THREE.MeshStandardMaterial({color:0xcdb8ee, metalness:0, roughness:1, transparent:true, opacity:0.16, depthWrite:false}),
rock: new THREE.MeshStandardMaterial({color:0x2e2444, metalness:0.05, roughness:1}),
glowTeal:new THREE.MeshBasicMaterial({color:0x8ef2dd}),
flameIn: new THREE.MeshBasicMaterial({color:0xd8fff4, transparent:true, opacity:0.85, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide}),
flameOut:new THREE.MeshBasicMaterial({color:0x5fe8cc, transparent:true, opacity:0.30, blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide})
};
/* ---------------- 积云 ---------------- */
const clouds = [];
function lumpySphere(r, seed){
const geo = new THREE.SphereGeometry(r, 22, 16);
const pos = geo.attributes.position;
const v = new THREE.Vector3();
for(let i=0;i<pos.count;i++){
v.fromBufferAttribute(pos, i);
const nz = Math.sin(v.x*2.6+seed) + Math.sin(v.y*3.1+seed*1.7) + Math.sin(v.z*2.2+seed*0.6);
v.multiplyScalar(1 + 0.13*Math.sin(nz*2.0));
pos.setXYZ(i, v.x, v.y, v.z);
}
geo.computeVertexNormals();
return geo;
}
function makeCloud(x,y,z, scale){
const g = new THREE.Group();
const n = 5 + Math.floor(Math.random()*4);
for(let i=0;i<n;i++){
const r = rand(1.4, 3.0);
const m = new THREE.Mesh(lumpySphere(r, Math.random()*10), M.cloud);
m.position.set(rand(-3.5,3.5), rand(-0.7,1.2)*r*0.4, rand(-1.8,1.8));
m.scale.y = 0.72;
g.add(m);
const shell = new THREE.Mesh(m.geometry, M.cloudShell);
shell.position.copy(m.position); shell.scale.set(1.25, 1.25*0.72, 1.25);
shell.renderOrder = 2;
g.add(shell);
}
g.position.set(x,y,z);
g.scale.setScalar(scale);
g.userData = {drift: rand(0.15,0.45)* (Math.random()<0.5?1:0.4), ph: Math.random()*10, baseY:y};
scene.add(g); clouds.push(g);
return g;
}
[[-30,15,-55,1.6],[18,20,-70,2.1],[45,12,-40,1.3],[-55,20,-30,1.8],[8,25,-90,2.6],
[-15,10,-25,0.9],[60,18,-75,1.9],[-70,14,-70,2.2],[30,9,-18,0.7]].forEach(a=>makeCloud(...a));
/* ---------------- 反重力浮台 ---------------- */
function makePad(radius){
const g = new THREE.Group();
const top = new THREE.Mesh(new THREE.CylinderGeometry(radius, radius, 0.18, 48),
new THREE.MeshStandardMaterial({color:0xdfe4ea, metalness:0.3, roughness:0.4}));
top.receiveShadow = true; top.castShadow = true;
g.add(top);
const rim = new THREE.Mesh(new THREE.TorusGeometry(radius, 0.055, 10, 64), M.glowTeal);
rim.rotation.x = Math.PI/2; rim.position.y = 0.02;
g.add(rim);
const under = new THREE.Mesh(new THREE.CylinderGeometry(radius*0.66, radius*0.22, 0.5, 32), M.dark);
under.position.y = -0.33; g.add(under);
const disc = new THREE.Mesh(new THREE.CircleGeometry(radius*0.2, 24), M.glowTeal);
disc.rotation.x = Math.PI/2; disc.position.y = -0.6; g.add(disc);
const halo = makeGlowSprite(0x6fe8d0, radius*2.6, radius*1.2, 0.22);
halo.position.y = -0.75; g.add(halo);
const perch = new THREE.Object3D(); perch.position.y = 0.09; g.add(perch);
g.userData.perch = perch; g.userData.rim = rim; g.userData.halo = halo;
scene.add(g);
return g;
}
const pad = makePad(1.7); pad.position.set(9, 0.9, 9);
/* ---------------- 浮岩礁石 ---------------- */
function rockBlob(r, seed){
const geo = new THREE.IcosahedronGeometry(r, 1);
const pos = geo.attributes.position; const v = new THREE.Vector3();
for(let i=0;i<pos.count;i++){
v.fromBufferAttribute(pos,i);
const nz = Math.sin(v.x*3.3+seed)*Math.sin(v.y*2.7+seed)*Math.sin(v.z*3.9+seed);
v.multiplyScalar(1 + 0.22*nz);
pos.setXYZ(i,v.x,v.y,v.z);
}
geo.computeVertexNormals();
const m = new THREE.Mesh(geo, M.rock);
m.castShadow = true; m.receiveShadow = true;
return m;
}
const rock = new THREE.Group();
[[0,0,0,1.5],[1.1,-0.3,0.4,1.0],[-0.9,-0.5,-0.3,0.9],[0.2,-0.9,0.7,0.8]].forEach(a=>{
const b = rockBlob(a[3], Math.random()*10);
b.position.set(a[0],a[1],a[2]); b.scale.y = 0.7;
rock.add(b);
});
rock.position.set(-11, 1.0, 3);
scene.add(rock);
const rockPerch = new THREE.Object3D(); rockPerch.position.set(-11, 1.55, 3.1); scene.add(rockPerch);
[[ -10.4, 0.7, 3.6 ],[ -11.6, 0.4, 2.4 ]].forEach(p=>{
const sp = makeGlowSprite(0x6fe8d0, 0.9, 0.9, 0.35); sp.position.set(...p); scene.add(sp);
});
/* 远处礁石剪影 */
[[-42,0.6,-62,6],[38,0.9,-78,9],[55,0.5,-45,4]].forEach(a=>{
const b = rockBlob(a[3], Math.random()*10);
b.position.set(a[0],a[1],a[2]); b.scale.y = 0.4;
b.material = new THREE.MeshStandardMaterial({color:0x151030, roughness:1});
scene.add(b);
});
/* ---------------- "just breathe" 信标 ---------------- */
const beacon = new THREE.Group();
const textTex = makeTextTexture(true);
const panel = new THREE.Mesh(new THREE.PlaneGeometry(5.6, 1.75),
new THREE.MeshBasicMaterial({map:textTex, transparent:true, blending:THREE.AdditiveBlending,
depthWrite:false, side:THREE.DoubleSide}));
panel.renderOrder = 3;
beacon.add(panel);
[[-0.78,1],[0.78,1]].forEach(a=>{
const bar = new THREE.Mesh(new THREE.BoxGeometry(5.9, 0.05, 0.05), M.glowTeal);
bar.position.y = a[0]; beacon.add(bar);
});
[[-3.0],[3.0]].forEach(a=>{
const ball = new THREE.Mesh(new THREE.SphereGeometry(0.09, 12, 10), M.glowTeal);
ball.position.set(a[0], 0.1, 0); beacon.add(ball);
const rod = new THREE.Mesh(new THREE.CylinderGeometry(0.015, 0.015, 0.9, 6),
new THREE.MeshBasicMaterial({color:0x9adfcc, transparent:true, opacity:0.5}));
rod.position.set(a[0]*0.86, 0.55, 0);
rod.rotation.z = a[0] > 0 ? -0.5 : 0.5;
beacon.add(rod);
});
const floatLine = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 1.5, 6),
new THREE.MeshBasicMaterial({color:0x9adfcc, transparent:true, opacity:0.4}));
floatLine.position.y = -1.1; beacon.add(floatLine);
const bob = new THREE.Mesh(new THREE.SphereGeometry(0.13, 14, 12), M.glowTeal);
bob.position.y = -1.9; beacon.add(bob);
const beaconGlow = makeGlowSprite(0x7df0dc, 9, 3.2, 0.16); beacon.add(beaconGlow);
const beaconLight = new THREE.PointLight(0x7df0dc, 0.7, 12);
beaconLight.position.y = -0.5; beacon.add(beaconLight);
beacon.position.set(4, 2.6, 10);
scene.add(beacon);
const beaconRefl = makeGlowSprite(0x5fe8cc, 5.5, 1.1, 0.18); scene.add(beaconRefl);
/* 天空深处的那行小字 */
const skyTextMat = new THREE.MeshBasicMaterial({map:makeTextTexture(false), transparent:true,
blending:THREE.AdditiveBlending, depthWrite:false, opacity:0.08, side:THREE.DoubleSide});
const skyText = new THREE.Mesh(new THREE.PlaneGeometry(26, 8.1), skyTextMat);
skyTextMat.map.repeat.set(0.86, 0.72); skyTextMat.map.offset.set(0.07, 0.14);
skyText.position.set(-26, 19, -92);
skyText.rotation.y = 0.28;
skyText.renderOrder = 2;
scene.add(skyText);
/* ---------------- 未来战斗机(白 · 绿 · 红)---------------- */
const jet = new THREE.Group();
{
const body = new THREE.Mesh(new THREE.SphereGeometry(0.78, 28, 20), M.white);
body.scale.set(0.85, 0.8, 2.3); body.position.z = 0.2;
jet.add(body);
const nose = new THREE.Mesh(new THREE.ConeGeometry(0.45, 1.5, 24), M.white);
nose.geometry.rotateX(Math.PI/2); nose.position.z = 2.35;
jet.add(nose);
const tip = new THREE.Mesh(new THREE.ConeGeometry(0.2, 0.5, 20), M.red);
tip.geometry.rotateX(Math.PI/2); tip.position.z = 3.05;
jet.add(tip);
const spine = new THREE.Mesh(new THREE.BoxGeometry(0.2, 0.06, 2.6), M.green);
spine.position.set(0, 0.63, 0.1); jet.add(spine);
const canopy = new THREE.Mesh(new THREE.SphereGeometry(0.4, 24, 18), M.glass);
canopy.scale.set(0.7, 0.55, 1.15); canopy.position.set(0, 0.42, 1.0);
jet.add(canopy);
[[0.68],[-0.68]].forEach(a=>{
const intake = new THREE.Mesh(new THREE.BoxGeometry(0.28, 0.3, 1.1), M.dark);
intake.position.set(a[0], -0.12, 0.5); jet.add(intake);
});
}
/* 机翼(含绿前缘 / 红翼尖),做成组便于镜像 */
function buildWing(){
const w = new THREE.Group();
const shape = new THREE.Shape();
shape.moveTo(0.35, 0.8); shape.lineTo(3.6, -0.9); shape.lineTo(3.9, -1.5); shape.lineTo(0.5, -1.5);
shape.closePath();
const geo = new THREE.ExtrudeGeometry(shape, {depth:0.1, bevelEnabled:true, bevelThickness:0.03, bevelSize:0.03, bevelSegments:1});
geo.rotateX(Math.PI/2);
const mat = M.white.clone(); mat.side = THREE.DoubleSide;
const mesh = new THREE.Mesh(geo, mat);
mesh.castShadow = true; mesh.receiveShadow = true;
w.add(mesh);
const led = new THREE.Mesh(new THREE.BoxGeometry(3.4, 0.06, 0.2), M.green);
led.position.set(1.97, 0.06, -0.05); led.rotation.y = 0.48;
w.add(led);
const tipRed = new THREE.Mesh(new THREE.BoxGeometry(0.55, 0.06, 0.75), M.red);
tipRed.position.set(3.6, 0.06, -1.25); tipRed.rotation.y = 0.48;
w.add(tipRed);
return w;
}
const wingR = buildWing(); jet.add(wingR);
const wingL = buildWing(); wingL.scale.x = -1; jet.add(wingL);
/* V 形垂尾(红尖)*/
[[-0.45,-0.5],[0.45,0.5]].forEach(a=>{
const fin = new THREE.Group();
const shape = new THREE.Shape();
shape.moveTo(0,0); shape.lineTo(-1.1,0.15); shape.lineTo(-1.35,0.9); shape.lineTo(-0.8,0.95);
shape.closePath();
const geo = new THREE.ExtrudeGeometry(shape, {depth:0.06, bevelEnabled:false});
const mat = M.white.clone(); mat.side = THREE.DoubleSide;
const m = new THREE.Mesh(geo, mat);
m.castShadow = true;
fin.add(m);
const red = new THREE.Mesh(new THREE.BoxGeometry(0.5, 0.2, 0.075), M.red);
red.position.set(-1.12, 0.85, 0.03);
fin.add(red);
fin.position.set(a[0], 0.32, -1.45);
fin.rotation.z = a[1];
jet.add(fin);
});
/* 引擎与尾焰 */
const flames = [];
[[-0.36],[0.36]].forEach(a=>{
const nac = new THREE.Mesh(new THREE.CylinderGeometry(0.24, 0.28, 0.9, 18), M.dark);
nac.geometry.rotateX(Math.PI/2); nac.position.set(a[0], -0.05, -2.15);
jet.add(nac);
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.22, 0.05, 10, 24), M.dark);
ring.position.set(a[0], -0.05, -2.6); jet.add(ring);
const disc = new THREE.Mesh(new THREE.CircleGeometry(0.19, 20), M.glowTeal);
disc.rotation.y = Math.PI; disc.position.set(a[0], -0.05, -2.58);
jet.add(disc);
const fIn = new THREE.Mesh(new THREE.ConeGeometry(0.17, 1.5, 14, 1, true), M.flameIn);
fIn.geometry.rotateX(-Math.PI/2); fIn.position.set(a[0], -0.05, -3.3);
const fOut = new THREE.Mesh(new THREE.ConeGeometry(0.24, 2.3, 14, 1, true), M.flameOut);
fOut.geometry.rotateX(-Math.PI/2); fOut.position.set(a[0], -0.05, -3.6);
jet.add(fIn); jet.add(fOut);
flames.push(fIn, fOut);
const gl = makeGlowSprite(0x7df0dc, 1.6, 1.6, 0.5);
gl.position.set(a[0], -0.05, -2.8); jet.add(gl);
});
/* 反重力腹环 */
const hoverRing = new THREE.Mesh(new THREE.TorusGeometry(0.5, 0.045, 10, 36), M.glowTeal);
hoverRing.rotation.x = Math.PI/2; hoverRing.position.set(0, -0.62, 0.3);
jet.add(hoverRing);
const hoverDisc = new THREE.Mesh(new THREE.CircleGeometry(0.42, 24), M.glowTeal);
hoverDisc.rotation.x = Math.PI/2; hoverDisc.position.set(0, -0.6, 0.3);
jet.add(hoverDisc);
const flameLight = new THREE.PointLight(0x7df0dc, 1.1, 12);
flameLight.position.set(0, 0, -3.2); jet.add(flameLight);
/* 机翼上的两个落脚点 */
const wingPerchR = new THREE.Object3D(); wingPerchR.position.set(2.55, 0.18, 1.35); jet.add(wingPerchR);
const wingPerchL = new THREE.Object3D(); wingPerchL.position.set(-2.55, 0.18, 1.35); jet.add(wingPerchL);
scene.add(jet);
const jetRefl = makeGlowSprite(0x8ef0e0, 7, 1.4, 0.25); scene.add(jetRefl);
/* 战机航线:沿岸往返,端点自然减速形成悬停感 */
const jetA = V3(-22, 4.2, -20), jetB = V3(14, 3.6, -4);
const jetPeriod = 40;
function jetPosAt(t, out){
const u = 0.5 - 0.5*Math.cos(t*Math.PI*2/jetPeriod);
out.lerpVectors(jetA, jetB, u);
out.y += Math.sin(t*1.1)*0.35;
out.x += Math.sin(t*0.5)*0.8;
out.z += Math.cos(t*0.7)*0.8;
return out;
}
/* ---------------- 太空兔 ---------------- */
const bunny = new THREE.Group();
const head = new THREE.Group();
const earL = new THREE.Group(), earR = new THREE.Group();
const armL = new THREE.Group(), armR = new THREE.Group();
const legL = new THREE.Group(), legR = new THREE.Group();
let eyeL, eyeR, antennaGlow, helmetLamp;
{
const body = new THREE.Mesh(new THREE.SphereGeometry(0.55, 26, 20), M.fur);
body.scale.set(1, 1.02, 0.92); body.position.y = 0.62;
bunny.add(body);
const tail = new THREE.Mesh(new THREE.SphereGeometry(0.15, 14, 12), M.fur);
tail.position.set(0, 0.72, -0.5);
bunny.add(tail);
/* 头 */
head.position.set(0, 1.3, 0.1);
const skull = new THREE.Mesh(new THREE.SphereGeometry(0.42, 26, 20), M.fur);
head.add(skull);
const nose = new THREE.Mesh(new THREE.SphereGeometry(0.05, 10, 8), M.pink);
nose.position.set(0, -0.02, 0.42); head.add(nose);
eyeL = new THREE.Mesh(new THREE.SphereGeometry(0.058, 12, 10), M.darkEye);
eyeL.position.set(-0.16, 0.08, 0.37); head.add(eyeL);
eyeR = eyeL.clone(); eyeR.position.x = 0.16; head.add(eyeR);
const hl = new THREE.Mesh(new THREE.SphereGeometry(0.018, 8, 6),
new THREE.MeshBasicMaterial({color:0xffffff}));
hl.position.set(-0.14, 0.12, 0.42); head.add(hl);
const hr = hl.clone(); hr.position.x = 0.18; head.add(hr);
/* 微斜的"酷"眉 */
[[-0.16, 0.1],[0.16, -0.1]].forEach(a=>{
const brow = new THREE.Mesh(new THREE.BoxGeometry(0.11, 0.022, 0.02), M.darkEye);
brow.position.set(a[0], 0.2, 0.38); brow.rotation.z = a[1];
head.add(brow);
});
/* 腮红 */
[[-0.27],[0.27]].forEach(a=>{
const ch = new THREE.Mesh(new THREE.SphereGeometry(0.055, 10, 8), M.pink);
ch.scale.set(1, 0.7, 0.4); ch.position.set(a[0], -0.06, 0.3);
ch.material = M.pink.clone(); ch.material.transparent = true; ch.material.opacity = 0.6;
head.add(ch);
});
/* 长耳朵(带粉色内耳)*/
[[earL, -0.16],[earR, 0.16]].forEach(a=>{
const g = a[0];
g.position.set(a[1], 0.3, -0.02);
const outer = new THREE.Mesh(new THREE.SphereGeometry(0.28, 16, 14), M.fur);
outer.scale.set(0.5, 1.6, 0.34); outer.position.y = 0.42;
outer.castShadow = true;
g.add(outer);
const inner = new THREE.Mesh(new THREE.SphereGeometry(0.26, 14, 12), M.pink);
inner.scale.set(0.3, 1.25, 0.1); inner.position.set(0, 0.42, 0.085);
g.add(inner);
head.add(g);
});
/* 透明宇航头盔 */
const glass = new THREE.Mesh(new THREE.SphereGeometry(0.53, 28, 22),
new THREE.MeshPhongMaterial({color:0xaaccdd, transparent:true, opacity:0.14,
shininess:120, specular:0xffffff, depthWrite:false}));
glass.renderOrder = 5;
head.add(glass);
const ring = new THREE.Mesh(new THREE.TorusGeometry(0.53, 0.028, 8, 40),
new THREE.MeshStandardMaterial({color:0xe8ecef, metalness:0.8, roughness:0.25}));
ring.rotation.x = Math.PI/2; ring.position.y = -0.02;
head.add(ring);
helmetLamp = new THREE.Mesh(new THREE.SphereGeometry(0.045, 10, 8), M.glowTeal);
helmetLamp.position.set(0.34, 0.42, 0.12); head.add(helmetLamp);
const lampGlow = makeGlowSprite(0x8ef2dd, 0.5, 0.5, 0.6);
lampGlow.position.copy(helmetLamp.position); head.add(lampGlow);
bunny.add(head);
/* 手手脚脚 */
[[armL, -0.45],[armR, 0.45]].forEach(a=>{
const arm = new THREE.Mesh(new THREE.SphereGeometry(0.1, 12, 10), M.fur);
arm.scale.set(0.8, 1.5, 0.8); arm.position.y = -0.12;
a[0].add(arm);
a[0].position.set(a[1], 0.85, 0.05);
bunny.add(a[0]);
});
[[legL, -0.2],[legR, 0.2]].forEach(a=>{
const leg = new THREE.Mesh(new THREE.SphereGeometry(0.13, 12, 10), M.fur);
leg.scale.set(0.85, 1.1, 0.9);
a[0].add(leg);
a[0].position.set(a[1], 0.16, 0.02);
bunny.add(a[0]);
});
/* 迷你喷气背包(白 / 绿 + 青焰口 + 闪烁天线)*/
const pack = new THREE.Group();
const pBody = new THREE.Mesh(new THREE.BoxGeometry(0.34, 0.4, 0.18), M.white);
pack.add(pBody);
const pCap = new THREE.Mesh(new THREE.BoxGeometry(0.3, 0.13, 0.02), M.green);
pCap.position.set(0, 0.08, -0.1); pack.add(pCap);
[[-0.1],[0.1]].forEach(a=>{
const noz = new THREE.Mesh(new THREE.CylinderGeometry(0.05, 0.065, 0.14, 10), M.dark);
noz.position.set(a[0], -0.26, -0.02); noz.rotation.x = 0.35;
pack.add(noz);
const fl = makeGlowSprite(0x8ef2dd, 0.3, 0.5, 0.5);
fl.position.set(a[0], -0.38, 0.05); pack.add(fl);
});
const ant = new THREE.Mesh(new THREE.CylinderGeometry(0.012, 0.012, 0.5, 6), M.dark);
ant.position.set(0.16, 0.42, 0); pack.add(ant);
const bulb = new THREE.Mesh(new THREE.SphereGeometry(0.032, 10, 8), M.glowTeal);
bulb.position.set(0.16, 0.68, 0); pack.add(bulb);
antennaGlow = makeGlowSprite(0x8ef2dd, 0.55, 0.55, 0.7);
antennaGlow.position.set(0.16, 0.68, 0); pack.add(antennaGlow);
pack.position.set(0, 0.88, -0.44);
bunny.add(pack);
/* 胸前小面板 */
const chest = new THREE.Mesh(new THREE.BoxGeometry(0.16, 0.1, 0.03), M.dark);
chest.position.set(0, 0.78, 0.48); bunny.add(chest);
const dot1 = new THREE.Mesh(new THREE.SphereGeometry(0.018, 8, 6), M.glowTeal);
dot1.position.set(-0.04, 0.78, 0.5); bunny.add(dot1);
const dot2 = new THREE.Mesh(new THREE.SphereGeometry(0.018, 8, 6), M.red);
dot2.material = M.red; dot2.position.set(0.04, 0.78, 0.5); bunny.add(dot2);
/* 兔子的暖光 */
const lamp = new THREE.PointLight(0xffe2c4, 0.75, 7);
lamp.position.set(0, 2.1, 0.6);
bunny.add(lamp);
bunny.traverse(o=>{ if(o.isMesh && o.material !== glass.material) o.castShadow = true; });
}
scene.add(bunny);
/* 落点(perch)登记 */
const perches = {
pad: Object.assign(pad.userData.perch, {isWing:false, yawOff:0}),
rock: Object.assign(rockPerch, {isWing:false, yawOff:0}),
wingR: Object.assign(wingPerchR, {isWing:true, yawOff:-0.45}),
wingL: Object.assign(wingPerchL, {isWing:true, yawOff: 0.45})
};
const route = ['pad', 'wingR', 'rock', 'wingL'];
/* ---------------- 兔子状态机 ---------------- */
const bs = {
perch: perches.pad, idx: 1,
mode: 'idle', t: 0, idleDur: 2.4,
hopFrom: new THREE.Vector3(), hopT: 0, hopDur: 1, hopH: 1.2,
squash: 1, squashV: 0,
yaw: 0, vy: 0, prevY: 0,
earAng: 0, lift: 0,
headTimer: 2, headYawT: 0, blinkT: 2.5, blinkPhase: 0
};
const tmpA = new THREE.Vector3(), tmpB = new THREE.Vector3(), tmpC = new THREE.Vector3();
const MAX_HOP_DIST = 18;
function perchIsWing(){ return bs.perch.isWing; }
function updateBunny(dt, t, jetRoll){
if(bs.mode === 'idle'){
bs.t += dt;
bs.perch.getWorldPosition(tmpA);
bunny.position.copy(tmpA);
/* 若下一站太远(战机还没掠过来),兔子就多等一会儿,顺便张望 */
const target = perches[route[bs.idx]];
target.getWorldPosition(tmpB);
const dist = tmpA.distanceTo(tmpB);
if(bs.t > bs.idleDur){
if(dist < MAX_HOP_DIST){
/* —— 起跳 —— */
bs.mode = 'hop'; bs.hopT = 0;
bs.hopFrom.copy(tmpA);
bs.hopDur = clamp(dist*0.16, 0.7, 1.9);
bs.hopH = clamp(dist*0.24, 0.9, 2.6);
bs.squash = 1.16; bs.squashV = 0;
}else{
bs.idleDur = bs.t + 1.0;
}
}
}else{
/* —— 滞空 —— */
bs.hopT += dt;
const k = Math.min(bs.hopT/bs.hopDur, 1);
const ke = k*k*(3-2*k);
const target = perches[route[bs.idx]];
target.getWorldPosition(tmpB);
tmpC.lerpVectors(bs.hopFrom, tmpB, ke);
tmpC.y += Math.sin(Math.PI*k)*bs.hopH;
bunny.position.copy(tmpC);
/* 朝向与俯仰 */
const dir = tmpA.subVectors(tmpB, bs.hopFrom);
bs.yaw = lerpAngle(bs.yaw, Math.atan2(dir.x, dir.z), Math.min(8*dt, 1));
bunny.rotation.y = bs.yaw;
bunny.rotation.x = clamp(-bs.vy*0.03, -0.3, 0.3);
if(k >= 1){
/* —— 落地 —— */
bs.mode = 'idle'; bs.t = 0;
bs.perch = target;
bs.idx = (bs.idx+1) % route.length;
bs.idleDur = perchIsWing() ? rand(6, 11) : rand(1.6, 3.0);
bs.squash = 0.7; bs.squashV = 0;
bunny.rotation.x = 0;
spawnRipple(bunny.position);
}
}
/* 站立时的朝向(等战机时望向战机,平时望向镜头方向)*/
if(bs.mode === 'idle'){
let want;
const nextTarget = perches[route[bs.idx]];
nextTarget.getWorldPosition(tmpB);
const dist = bunny.position.distanceTo(tmpB);
const toCam = Math.atan2(camera.position.x - bunny.position.x, camera.position.z - bunny.position.z);
if(nextTarget.isWing && dist > 10){
const toJet = Math.atan2(jet.position.x - bunny.position.x, jet.position.z - bunny.position.z);
want = toJet*0.65 + toCam*0.35 + Math.sin(t*0.6)*0.1;
}else if(perchIsWing()){
want = jet.rotation.y + bs.perch.yawOff;
}else{
want = toCam*0.3 + Math.sin(t*0.35)*0.5;
}
bs.yaw = lerpAngle(bs.yaw, want, Math.min(2.5*dt, 1));
bunny.rotation.y = bs.yaw;
/* 站在翼上时随 roll 微微平衡 */
const wantZ = perchIsWing() ? -jetRoll*0.55 : 0;
bunny.rotation.z += (wantZ - bunny.rotation.z)*Math.min(3*dt, 1);
}
/* 落点高度对齐(战机移动由 perch 世界坐标自动带出)*/
/* 弹性(squash & stretch)*/
bs.vy = (bunny.position.y - bs.prevY)/Math.max(dt, 1e-4);
bs.prevY = bunny.position.y;
bs.squashV += (1 - bs.squash)*60*dt;
bs.squashV *= Math.exp(-9*dt);
bs.squash += bs.squashV*dt;
const air = bs.mode === 'hop' ? 1 + clamp(bs.vy*0.045, -0.12, 0.22) : 1;
const sy = bs.squash*air, sxz = 1/Math.sqrt(sy);
bunny.scale.set(sxz, sy, sxz);
/* 耳朵弹簧 */
const earT = Math.sin(t*2.4 + bs.yaw)*0.10 + (bs.mode==='hop' ? clamp(-bs.vy*0.09, -0.55, 0.55) : 0);
bs.earAng += (earT - bs.earAng)*Math.min(9*dt, 1);
earL.rotation.z = 0.16 + bs.earAng;
earR.rotation.z = -0.16 - bs.earAng;
earL.rotation.x = earR.rotation.x = -bs.earAng*0.7;
/* 手脚的腾空收展 */
const liftT = bs.mode === 'hop' ? Math.sin(Math.PI*Math.min(bs.hopT/bs.hopDur,1)) : 0;
bs.lift += (liftT - bs.lift)*Math.min(10*dt, 1);
armL.rotation.z = 0.3 + bs.lift*1.15;
armR.rotation.z = -0.3 - bs.lift*1.15;
legL.position.y = legR.position.y = 0.16 - bs.lift*0.07;
legL.scale.y = legR.scale.y = 1 - bs.lift*0.35;
/* 头部:偶尔看向镜头 */
bs.headTimer -= dt;
if(bs.headTimer < 0){
bs.headTimer = rand(2.5, 6);
if(Math.random() < 0.55){
const camYaw = Math.atan2(camera.position.x - bunny.position.x, camera.position.z - bunny.position.z);
bs.headYawT = clamp(((camYaw - bs.yaw + Math.PI*3) % (Math.PI*2)) - Math.PI, -0.7, 0.7);
}else bs.headYawT = rand(-0.5, 0.5);
}
head.rotation.y += (bs.headYawT - head.rotation.y)*Math.min(4*dt, 1);
head.rotation.x = Math.sin(t*1.3)*0.04;
/* 眨眼 */
bs.blinkT -= dt;
if(bs.blinkT < 0){ bs.blinkT = rand(2, 5); bs.blinkPhase = 0.15; }
if(bs.blinkPhase > 0) bs.blinkPhase -= dt;
const es = bs.blinkPhase > 0 ? 0.12 : 1;
eyeL.scale.y = eyeR.scale.y = es;
/* 信号灯 */
antennaGlow.material.opacity = 0.35 + 0.45*(0.5 + 0.5*Math.sin(t*4.5));
helmetLamp.scale.setScalar(1 + 0.15*Math.sin(t*4.5));
}
/* ---------------- 落地涟漪(对象池) ---------------- */
const ripples = [];
for(let i=0;i<4;i++){
const m = new THREE.Mesh(new THREE.RingGeometry(0.82, 1, 48),
new THREE.MeshBasicMaterial({color:0xaef5e6, transparent:true, opacity:0,
blending:THREE.AdditiveBlending, depthWrite:false, side:THREE.DoubleSide}));
m.rotation.x = -Math.PI/2; m.visible = false; m.renderOrder = 4;
scene.add(m);
ripples.push({mesh:m, life:-1});
}
function spawnRipple(pos){
const r = ripples.find(r=>r.life < 0) || ripples[0];
r.life = 0;
r.mesh.visible = true;
r.mesh.position.set(pos.x, pos.y + 0.06, pos.z);
}
function updateRipples(dt){
ripples.forEach(r=>{
if(r.life < 0) return;
r.life += dt;
const k = r.life/0.9;
if(k >= 1){ r.life = -1; r.mesh.visible = false; return; }
r.mesh.scale.setScalar(0.35 + k*2.4);
r.mesh.material.opacity = (1-k)*0.55;
});
}
/* ---------------- 漂浮微尘 ---------------- */
function makeDust(count, color, size, opacity, range){
const geo = new THREE.BufferGeometry();
const arr = new Float32Array(count*3);
const meta = [];
for(let i=0;i<count;i++){
arr[i*3] = rand(-range, range);
arr[i*3+1] = rand(0, 24);
arr[i*3+2] = rand(-range*1.4, range*0.7);
meta.push({b:arr[i*3+1], s:rand(0.15,0.5), p:rand(0,20)});
}
geo.setAttribute('position', new THREE.BufferAttribute(arr, 3));
const mat = new THREE.PointsMaterial({color:color, size:size, map:softGlow, transparent:true,
opacity:opacity, blending:THREE.AdditiveBlending, depthWrite:false});
const pts = new THREE.Points(geo, mat);
scene.add(pts);
return {pts, meta, arr};
}
const dustA = makeDust(260, 0xcbb6ff, 0.5, 0.5, 55);
const dustB = makeDust(70, 0x8ef2dd, 0.3, 0.7, 40);
function updateDust(d, t){
for(let i=0;i<d.meta.length;i++){
const m = d.meta[i];
d.arr[i*3+1] = 1 + ((m.b + t*m.s*0.6) % 23);
}
d.pts.geometry.attributes.position.needsUpdate = true;
d.pts.material.opacity = 0.5*(0.65 + 0.35*Math.sin(t*0.7));
}
/* ---------------- 镜头导演 ---------------- */
const shotNames = ['入梦', '低语海面', '翼上同行', '掠海', '浮台特写'];
const shots = [
{ dur:15, fov:[50,43],
update(t, T, o){
o.pos.set(-28 + t*0.38, 9.5 - t*0.12, 34 - t*0.55);
o.look.set(2, 3.2, -8);
}},
{ dur:11, fov:[40,40],
update(t, T, o){
pad.getWorldPosition(tmpA);
o.pos.set(tmpA.x - 4.4 + Math.sin(t*0.3)*0.5, tmpA.y + 1.4 + Math.sin(t*0.45)*0.2, tmpA.z + 4.6);
o.look.copy(bunny.position).y += 0.5;
}},
{ dur:12, fov:[44,44],
update(t, T, o){
const a = t*0.07 + 2.5;
o.pos.set(jet.position.x + Math.sin(a)*7.5, jet.position.y + 1.7 + Math.sin(t*0.4)*0.3, jet.position.z + Math.cos(a)*7.5);
if(perchIsWing() || bs.mode==='hop') o.look.copy(bunny.position);
else o.look.copy(jet.position);
}},
{ dur:10, fov:[36,33],
update(t, T, o){
o.pos.set(12 + Math.sin(t*0.2)*1.5, 1.7, 17 - t*0.25);
o.look.copy(jet.position); o.look.y += 0.4;
}},
{ dur:11, fov:[40,40],
update(t, T, o){
const a = t*0.10 + 0.8;
o.pos.set(bunny.position.x + Math.sin(a)*3.6, bunny.position.y + 0.85 + Math.sin(t*0.5)*0.15, bunny.position.z + Math.cos(a)*3.6);
o.look.copy(bunny.position); o.look.y += 0.55;
}}
];
let shotIdx = 0, shotT = 0, camFov = 50, shotCooldown = 0;
const camPos = V3(-28, 9.5, 34), camLook = V3(2, 3.2, -8);
const desPos = new THREE.Vector3(), desLook = new THREE.Vector3();
let mouseNX = 0, mouseNY = 0;
const _right = new THREE.Vector3(), _up = new THREE.Vector3(0,1,0);
const shotEl = document.getElementById('shotname');
function showShotName(){
shotEl.textContent = shotNames[shotIdx];
shotEl.classList.remove('show');
void shotEl.offsetWidth;
shotEl.classList.add('show');
}
function nextShot(){
if(shotCooldown > 0) return;
shotCooldown = 1.2;
shotIdx = (shotIdx + 1) % shots.length;
shotT = 0;
showShotName();
}
showShotName();
function updateCamera(dt, t){
shotCooldown = Math.max(0, shotCooldown - dt);
shotT += dt;
const s = shots[shotIdx];
if(shotT > s.dur) nextShot();
const tt = Math.min(shotT, s.dur);
s.update(tt, s.dur, {pos:desPos, look:desLook});
/* 呼吸漂移 */
desPos.x += Math.cos(t*0.3)*0.3; desPos.y += Math.sin(t*0.42)*0.25;
desLook.y += Math.sin(t*0.35)*0.08;
/* 阻尼追踪(镜头切换时自然滑移)*/
camPos.lerp(desPos, 1 - Math.exp(-1.6*dt));
camLook.lerp(desLook, 1 - Math.exp(-2.2*dt));
camFov += (s.fov[0] + (s.fov[1]-s.fov[0])*(tt/s.dur) - camFov)*(1 - Math.exp(-1.8*dt));
camera.fov = camFov;
camera.updateProjectionMatrix();
camera.position.copy(camPos);
camera.lookAt(camLook);
/* 鼠标视差 + 极轻微手持呼吸 */
_right.setFromMatrixColumn(camera.matrix, 0);
camera.position.addScaledVector(_right, mouseNX*0.5).addScaledVector(_up, -mouseNY*0.3);
camera.rotation.z += Math.sin(t*0.23)*0.006 + mouseNX*0.01;
}
/* ---------------- 交互 ---------------- */
addEventListener('pointermove', e=>{
mouseNX = (e.clientX/innerWidth)*2 - 1;
mouseNY = (e.clientY/innerHeight)*2 - 1;
});
addEventListener('pointerdown', nextShot);
addEventListener('resize', ()=>{
camera.aspect = innerWidth/innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
});
/* ---------------- 主循环 ---------------- */
let last = performance.now(), T = 0;
const prevJetPos = jetPosAt(0, new THREE.Vector3());
let jetYaw = 0, jetRoll = 0, jetSpeed = 0;
const jetFuture = new THREE.Vector3(), jetCur = new THREE.Vector3();
function tick(){
requestAnimationFrame(tick);
const now = performance.now();
const dt = Math.min((now - last)/1000, 0.05);
last = now; T += dt;
const t = T;
skyMat.uniforms.uTime.value = t;
seaMat.uniforms.uTime.value = t;
/* 云 */
clouds.forEach(c=>{
c.position.x += c.userData.drift*dt;
if(c.position.x > 95) c.position.x = -95;
c.position.y = c.userData.baseY + Math.sin(t*0.24 + c.userData.ph)*0.6;
});
/* 浮台呼吸 */
pad.position.y = 0.9 + Math.sin(t*0.8)*0.12;
pad.userData.rim.scale.setScalar(1 + 0.04*Math.sin(t*1.6));
pad.userData.halo.material.opacity = 0.18 + 0.08*Math.sin(t*1.6);
rock.position.y = 1.0 + Math.sin(t*0.6 + 2)*0.09;
/* 信标 */
beacon.position.y = 2.6 + Math.sin(t*0.6)*0.18;
beacon.rotation.y = Math.sin(t*0.23)*0.3;
beaconGlow.material.opacity = 0.12 + 0.07*(0.5 + 0.5*Math.sin(t*0.9));
beaconLight.intensity = 0.55 + 0.25*(0.5 + 0.5*Math.sin(t*0.9));
beaconRefl.position.set(beacon.position.x, 0.4, beacon.position.z);
beaconRefl.material.opacity = 0.13 + 0.06*Math.sin(t*0.9);
skyTextMat.opacity = 0.05 + 0.06*(0.5 + 0.5*Math.sin(t*0.4));
/* 战机 */
jetPosAt(t, jetCur);
jetPosAt(t + 0.35, jetFuture);
jet.position.copy(jetCur);
jet.lookAt(jetFuture);
jetSpeed = jetCur.distanceTo(prevJetPos)/Math.max(dt, 1e-4);
prevJetPos.copy(jetCur);
const targetYaw = Math.atan2(jetFuture.x - jetCur.x, jetFuture.z - jetCur.z);
let yawDelta = ((targetYaw - jetYaw + Math.PI*3) % (Math.PI*2)) - Math.PI;
jetRoll += (clamp(yawDelta*6, -0.55, 0.55) + Math.sin(t*0.9)*0.05 - jetRoll)*Math.min(3*dt, 1);
jet.rotateZ(-jetRoll);
jetYaw = targetYaw;
const flameLen = 0.75 + jetSpeed*0.09 + Math.sin(t*30)*0.1;
flames.forEach((f,i)=>{ f.scale.set(1, (i%2 ? 1.15 : 1)*flameLen, 1); });
flameLight.intensity = 0.9 + jetSpeed*0.06 + Math.sin(t*25)*0.15;
hoverRing.scale.setScalar(1 + 0.06*Math.sin(t*2.2));
/* 兔子 */
updateBunny(dt, t, jetRoll);
/* 倒影光斑 */
jetRefl.position.set(jet.position.x, 0.35, jet.position.z);
jetRefl.material.opacity = clamp(0.4 - jet.position.y*0.045, 0.08, 0.35)*(0.8 + 0.2*Math.sin(t*3));
jetRefl.scale.set(5 + jetSpeed*0.4, 1.3, 1);
if(bs.mode !== 'hop' || bs.hopT > 0.2){
/* 落点处的水面微光跟随兔子 */
}
/* 粒子与涟漪 */
updateDust(dustA, t);
updateDust(dustB, t*1.2);
updateRipples(dt);
/* 镜头 */
updateCamera(dt, t);
renderer.render(scene, camera);
}
tick();
</script>
</body>
</html>
实现说明(要点):
- 角色:太空兔由纯几何体拼装——Q 版头身比、长耳带粉色内耳、斜眉与腮红给性格,外面罩一层低透明度宇航头盔(金属环 + 顶部信号灯),背上是白绿配色迷你喷气背包 + 闪烁天线。耳朵、手臂、squash & stretch 全部用弹簧插值驱动:起跳蹬伸、滞空拉伸、落地压扁回弹,耳朵随垂直速度后掠;它还会眨眼、转头看镜头、在浮台上等战机掠过时张望——这些小动作是"性格"的来源。
- 叙事循环:兔子在 浮台 → 右翼 → 礁石 → 左翼 之间循环蹦跳。落点是实时追踪的世界坐标,所以跳上机翼后会随机枪一起掠海巡航一段再跳回岸边;目标太远时它会原地等待,跳跃距离自适应时长与弧高,落地有扩散涟漪。
- 战机:白 / 绿 / 红涂装(绿背脊与翼前缘、红鼻锥翼尖垂尾尖),双发尾焰随航速伸缩,沿一条余弦往返航线沿岸巡飞,端点自然减速成悬停,转弯带横滚,机腹有反重力光环。
- 氛围:天空、海面均为自定义 shader(渐变 + 星野 + 大月亮银河带;海面有菲涅耳天空反射、月光高光带、碎金闪点与手动雾),积云为噪声变形球 + 半透明外壳的伪体积云,全部发光体用加性光晕模拟辉光,避免后处理依赖。
- "just breathe":canvas 手工绘制发光字(逐字符字距 + 多层柔光),做成立在海上、系在浮标上的"LUNA-7 信标",缓缓摇摆呼吸;远处天空还有一行明灭的同款小字。
- 镜头:五组电影机位(入梦 / 低语海面 / 窗上同行 / 掠海 / 浮台特写)自动轮换,位置、注视点、FOV 全部阻尼插值,叠加呼吸漂移、极轻微手持感与鼠标视差;点击画面可切换机位。
直接保存为 .html 双击打开即可运行,仅依赖一个 Three.js CDN。
Timing details
- Actual wait (client)
- 1.05s
- Client preparation
- 0.04s
- Proxy preparation
- 0.00s
- Upstream response headers
- 0.77s
- Upstream first byte
- 0.77s
- Upstream first token
- 0.77s
- Upstream retries
- 0
Actual wait uses the client clock. Upstream timings start after proxy preparation and are cumulative; do not add these values. Upstream delay includes network, scheduling, processing and buffering; it does not prove provider queueing.
Diagnostic receipt
{
"attempts": 1,
"startedAt": "2026-09-29T03:43:55.804Z",
"transport": "railway",
"ttftSource": "proxy",
"clientTtftMs": 1048.6999998092651,
"completionId": "2026092911435594adafc27dce451f",
"streamResumes": 23,
"proxyPrepareMs": 0,
"proxyRequestId": "wToovwptSyiO9BVVlt7tkg",
"upstreamStatus": 200,
"upstreamTtftMs": 773,
"clientHeadersMs": 251.5,
"clientPrepareMs": 45,
"upstreamHeadersMs": 773,
"upstreamFirstByteMs": 773,
"clientFirstContentMs": 840194.2999997139
}<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Space Bunny — just breathe</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
html,body{width:100%;height:100%;overflow:hidden;background:#060620}
canvas{display:block}
#loader{position:fixed;inset:0;z-index:200;display:flex;flex-direction:column;align-items:center;justify-content:center;background:#060620;color:#a78bfa;font-family:Georgia,serif;transition:opacity 1.5s ease}
#loader.done{opacity:0;pointer-events:none}
#loader .txt{font-size:15px;letter-spacing:6px;animation:pl 2s ease-in-out infinite}
@keyframes pl{0%,100%{opacity:.3}50%{opacity:1}}
#hud{position:fixed;bottom:18px;left:50%;transform:translateX(-50%);color:rgba(167,139,250,.25);font-size:10px;letter-spacing:3px;font-family:Georgia,serif;pointer-events:none;z-index:50}
</style>
</head>
<body>
<div id="loader"><div class="txt">space bunny is dreaming…</div></div>
<div id="hud">drag to orbit · scroll to zoom</div>
<canvas id="c"></canvas>
<script>
window.addEventListener('error',function(e){
var l=document.getElementById('loader');
if(l&&!l.classList.contains('done')){
var t=l.querySelector('.txt');
if(t)t.textContent='WebGL not supported :(';
}
});
</script>
<script type="importmap">
{"imports":{
"three":"https://unpkg.com/three@0.160.0/build/three.module.js",
"three/addons/":"https://unpkg.com/three@0.160.0/examples/jsm/"
}}
</script>
<script type="module">
/* ══════════════════════════════════════════════════════════════════
SPACE BUNNY — "just breathe"
A dreamy Web3D scene: a cute astronaut bunny hops over a shimmering
sea beneath purple clouds, alongside a white / green / red fighter jet.
══════════════════════════════════════════════════════════════════ */
import * as THREE from 'three';
import {OrbitControls} from 'three/addons/controls/OrbitControls.js';
import {EffectComposer} from 'three/addons/postprocessing/EffectComposer.js';
import {RenderPass} from 'three/addons/postprocessing/RenderPass.js';
import {UnrealBloomPass} from 'three/addons/postprocessing/UnrealBloomPass.js';
import {ShaderPass} from 'three/addons/postprocessing/ShaderPass.js';
/* ─── RENDERER / SCENE / CAMERA ────────────────────────────────── */
const canvas = document.getElementById('c');
const renderer = new THREE.WebGLRenderer({ canvas, antialias: true });
renderer.setSize(innerWidth, innerHeight);
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.15;
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x0c0630, 0.005);
const camera = new THREE.PerspectiveCamera(50, innerWidth / innerHeight, 0.1, 500);
camera.position.set(30, 18, 40);
const controls = new OrbitControls(camera, canvas);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 4, -3);
controls.minDistance = 8;
controls.maxDistance = 60;
controls.maxPolarAngle = Math.PI * 0.47;
controls.enabled = false;
let userInteracting = false;
canvas.addEventListener('pointerdown', () => {
userInteracting = true;
controls.enabled = true;
});
/* ─── POST-PROCESSING ──────────────────────────────────────────── */
const composer = new EffectComposer(renderer);
composer.addPass(new RenderPass(scene, camera));
const bloom = new UnrealBloomPass(new THREE.Vector2(innerWidth, innerHeight), 0.7, 0.5, 0.82);
composer.addPass(bloom);
composer.addPass(new ShaderPass({
uniforms: { tDiffuse: { value: null }, strength: { value: 0.45 } },
vertexShader: `varying vec2 vUv;void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,
fragmentShader: `uniform sampler2D tDiffuse;uniform float strength;varying vec2 vUv;
void main(){vec4 c=texture2D(tDiffuse,vUv);float d=distance(vUv,vec2(.5));
c.rgb*=1.-smoothstep(.35,.75,d)*strength;gl_FragColor=c;}`
}));
/* ─── LIGHTS ───────────────────────────────────────────────────── */
scene.add(new THREE.HemisphereLight(0x4433aa, 0x0a0a2e, 0.7));
const moonLight = new THREE.DirectionalLight(0xccddff, 1.2);
moonLight.position.set(30, 50, -40);
moonLight.castShadow = true;
moonLight.shadow.mapSize.set(2048, 2048);
const sc = moonLight.shadow.camera;
sc.near = 10; sc.far = 120;
sc.left = -35; sc.right = 35; sc.top = 35; sc.bottom = -35;
scene.add(moonLight);
const rimLight = new THREE.DirectionalLight(0x8866ff, 0.5);
rimLight.position.set(-20, 8, -15);
scene.add(rimLight);
const warmFill = new THREE.DirectionalLight(0xffaa66, 0.25);
warmFill.position.set(5, 3, 15);
scene.add(warmFill);
/* ─── SKY DOME ─────────────────────────────────────────────────── */
const skyMat = new THREE.ShaderMaterial({
side: THREE.BackSide,
uniforms: {
top: { value: new THREE.Color(0x04041a) },
mid: { value: new THREE.Color(0x120838) },
low: { value: new THREE.Color(0x2a1560) },
horizon:{ value: new THREE.Color(0x4a2580) }
},
vertexShader: `varying vec3 vD;void main(){vD=normalize(position);gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.);}`,
fragmentShader: `uniform vec3 top,mid,low,horizon;varying vec3 vD;
void main(){float h=vD.y;vec3 c;
if(h>.4)c=mix(mid,top,smoothstep(.4,1.,h));
else if(h>0.)c=mix(horizon,mid,smoothstep(0.,.4,h));
else c=mix(horizon,low,smoothstep(0.,-.3,h));
gl_FragColor=vec4(c,1.);}`
});
scene.add(new THREE.Mesh(new THREE.SphereGeometry(250, 32, 32), skyMat));
/* ─── MOON ─────────────────────────────────────────────────────── */
const moon = new THREE.Mesh(new THREE.SphereGeometry(4, 24, 24), new THREE.MeshBasicMaterial({ color: 0xffffee }));
moon.position.set(50, 65, -80);
scene.add(moon);
const moonGlow = new THREE.Mesh(
new THREE.SphereGeometry(8, 16, 16),
new THREE.MeshBasicMaterial({ color: 0xffeedd, transparent: true, opacity: 0.08, side: THREE.BackSide })
);
moonGlow.position.copy(moon.position);
scene.add(moonGlow);
/* ─── STARS ────────────────────────────────────────────────────── */
const STAR_COUNT = 2000;
const starGeo = new THREE.BufferGeometry();
{
const pos = new Float32Array(STAR_COUNT * 3);
const sz = new Float32Array(STAR_COUNT);
for (let i = 0; i < STAR_COUNT; i++) {
const th = Math.random() * Math.PI * 2;
const ph = Math.acos(Math.random() * 2 - 1);
const r = 190 + Math.random() * 50;
pos[i*3] = r * Math.sin(ph) * Math.cos(th);
pos[i*3+1] = r * Math.sin(ph) * Math.sin(th);
pos[i*3+2] = r * Math.cos(ph);
sz[i] = 0.5 + Math.random() * 2.5;
}
starGeo.setAttribute('position', new THREE.BufferAttribute(pos, 3));
starGeo.setAttribute('size', new THREE.BufferAttribute(sz, 1));
}
scene.add(new THREE.Points(starGeo, new THREE.ShaderMaterial({
transparent: true, depthWrite: false, blending: THREE.AdditiveBlending,
vertexShader: `attribute float size;void main(){vec4 mv=modelViewMatrix*vec4(position,1.);
gl_PointSize=size*(500./-mv.z);gl_Position=projectionMatrix*mv;}`,
fragmentShader: `void main(){float d=length(gl_PointCoord-.5);if(d>.5)discard;
gl_FragColor=vec4(.9,.85,1.,smoothstep(.5,0.,d)*.7);}`
})));
/* ─── WATER ────────────────────────────────────────────────────── */
const waterGeo = new THREE.PlaneGeometry(500, 500, 256, 256);
waterGeo.rotateX(-Math.PI / 2);
const waterMat = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uCam: { value: new THREE.Vector3() },
uSkyTop:{ value: new THREE.Color(0x120838) },
uSkyHor:{ value: new THREE.Color(0x4a2580) },
uMoonD: { value: new THREE.Vector3(30, 50, -40).normalize() },
uMoonC: { value: new THREE.Color(0xffeedd) }
},
vertexShader: `
uniform float uTime;
varying vec3 vWP; varying vec3 vN;
float w(vec2 p,float t){
return sin(p.x*.35+t*.7)*.12+sin(p.y*.28+t*.5)*.10
+ sin((p.x+p.y)*.22+t*.35)*.15+sin(p.x*.6-t*.8)*.05
+ sin(p.y*.5+t*.6)*.04;
}
void main(){
vec3 pos=position; float h=w(pos.xz,uTime); pos.y+=h;
float e=.4;
float hx=w(pos.xz+vec2(e,0),uTime)-h;
float hz=w(pos.xz+vec2(0,e),uTime)-h;
vN=normalize(vec3(-hx/e,1.,-hz/e));
vec4 wp=modelMatrix*vec4(pos,1.); vWP=wp.xyz;
gl_Position=projectionMatrix*viewMatrix*wp;
}`,
fragmentShader: `
uniform float uTime;
uniform vec3 uCam,uSkyTop,uSkyHor,uMoonD,uMoonC;
varying vec3 vWP; varying vec3 vN;
void main(){
vec3 V=normalize(uCam-vWP); vec3 N=normalize(vN);
float fres=pow(1.-max(dot(V,N),0.),3.5); fres=clamp(fres,0.,1.);
vec3 deep=vec3(.008,.015,.06); vec3 shall=vec3(.02,.04,.12);
vec3 refD=reflect(-V,N);
vec3 skyRef=mix(uSkyHor,uSkyTop,clamp(refD.y,0.,1.));
vec3 col=mix(deep,shall,fres);
col=mix(col,skyRef,fres*.85);
vec3 H=normalize(uMoonD+V);
col+=uMoonC*pow(max(dot(N,H),0.),400.)*4.;
col+=uMoonC*pow(max(dot(N,H),0.),60.)*.4;
float sp=sin(vWP.x*2.5+uTime*1.3)*sin(vWP.z*3.-uTime*.9);
sp+=sin(vWP.x*6.-uTime*1.8)*sin(vWP.z*7.+uTime*1.4);
col+=vec3(.3,.25,.5)*pow(max(sp*.5,0.),6.)*.4*fres;
float dist=length(uCam-vWP);
col=mix(col,vec3(.06,.03,.15),smoothstep(100.,350.,dist));
gl_FragColor=vec4(col,1.);
}`
});
const water = new THREE.Mesh(waterGeo, waterMat);
scene.add(water);
/* ─── CLOUDS ───────────────────────────────────────────────────── */
const cloudGroup = new THREE.Group();
scene.add(cloudGroup);
function makeCloud(cx, cy, cz, scale, hue) {
const g = new THREE.Group();
const c = new THREE.Color().setHSL(hue, 0.45, 0.42);
const mat = new THREE.MeshStandardMaterial({
color: c, transparent: true, opacity: 0.5,
roughness: 1, metalness: 0, emissive: c, emissiveIntensity: 0.12
});
const n = 5 + Math.floor(Math.random() * 5);
for (let i = 0; i < n; i++) {
const r = (0.8 + Math.random() * 1.5) * scale;
const puff = new THREE.Mesh(new THREE.SphereGeometry(r, 8, 6), mat);
puff.position.set(
(Math.random() - 0.5) * 5 * scale,
(Math.random() - 0.3) * 1.2 * scale,
(Math.random() - 0.5) * 3.5 * scale
);
puff.scale.y = 0.5 + Math.random() * 0.25;
g.add(puff);
}
g.position.set(cx, cy, cz);
g.userData = { drift: (Math.random() - 0.5) * 0.4, baseY: cy, phase: Math.random() * 6.28 };
return g;
}
[[-35,22,-60,3,.72],[30,18,-70,4,.75],[-45,25,-25,2.5,.7],
[40,28,-35,3.5,.74],[-25,30,-80,5,.73],[45,20,-90,3,.76],
[0,26,-100,4.5,.72],[-55,15,-45,3,.75],[55,24,-60,3.5,.71],
[-18,32,-50,4,.73],[35,35,-70,5,.74],[-40,20,-18,2.5,.76],
[22,24,-45,3,.73],[-28,16,10,1.8,.74],[15,18,15,2,.72]
].forEach(([x,y,z,s,h]) => cloudGroup.add(makeCloud(x,y,z,s,h)));
/* ─── FIGHTER JET ──────────────────────────────────────────────── */
function createFighterJet() {
const jet = new THREE.Group();
const mW = new THREE.MeshStandardMaterial({ color: 0xeeeeee, metalness: .2, roughness: .45, side: THREE.DoubleSide });
const mG = new THREE.MeshStandardMaterial({ color: 0x22bb66, metalness: .4, roughness: .3, side: THREE.DoubleSide });
const mR = new THREE.MeshStandardMaterial({ color: 0xcc3344, metalness: .4, roughness: .3, side: THREE.DoubleSide });
const mD = new THREE.MeshStandardMaterial({ color: 0x1a1a30, metalness: .7, roughness: .25 });
const mGlass = new THREE.MeshPhysicalMaterial({ color: 0x66ddff, transparent: true, opacity: .4, roughness: .05, clearcoat: 1 });
const mGlow = new THREE.MeshBasicMaterial({ color: 0x44ddff, transparent: true, opacity: .85 });
const bg = new THREE.SphereGeometry(1, 16, 12); bg.scale(3.2, .7, .6);
const body = new THREE.Mesh(bg, mW); body.castShadow = true; jet.add(body);
const ng = new THREE.ConeGeometry(.25, 1.5, 8); ng.rotateZ(-Math.PI / 2);
const nose = new THREE.Mesh(ng, mW); nose.position.set(3.5, 0, 0); jet.add(nose);
function wingGeom(sign) {
const s = new THREE.Shape();
if (sign > 0) { s.moveTo(1.5,0); s.lineTo(-2.5,4.5); s.lineTo(-3,4.5); s.lineTo(-1,0); }
else { s.moveTo(1.5,0); s.lineTo(-1,0); s.lineTo(-3,-4.5); s.lineTo(-2.5,-4.5); }
s.closePath();
const g = new THREE.ExtrudeGeometry(s, { depth: .07, bevelEnabled: false });
g.rotateX(Math.PI / 2); g.translate(0, .035, 0);
return g;
}
const lW = new THREE.Mesh(wingGeom(1), mG); lW.castShadow = true; jet.add(lW);
const rW = new THREE.Mesh(wingGeom(-1), mG); rW.castShadow = true; jet.add(rW);
const ts = new THREE.Shape();
ts.moveTo(-1.5,0); ts.lineTo(-2,2.5); ts.lineTo(-2.8,2.5); ts.lineTo(-2.8,0); ts.closePath();
const tail = new THREE.Mesh(new THREE.ExtrudeGeometry(ts,{depth:.07,bevelEnabled:false}), mR);
tail.position.set(0,.5,-.035); jet.add(tail);
const ls = new THREE.Mesh(new THREE.BoxGeometry(1.2,.06,1.5), mR);
ls.position.set(-2.5,.1,1); ls.rotation.y=.15; jet.add(ls);
const rs = new THREE.Mesh(new THREE.BoxGeometry(1.2,.06,1.5), mR);
rs.position.set(-2.5,.1,-1); rs.rotation.y=-.15; jet.add(rs);
const cg = new THREE.SphereGeometry(.4,12,8,0,Math.PI*2,0,Math.PI/2);
const canopy = new THREE.Mesh(cg, mGlass);
canopy.position.set(1.5,.5,0); canopy.scale.set(1.6,1,1); jet.add(canopy);
const eg = new THREE.CylinderGeometry(.18,.22,1.2,8); eg.rotateZ(Math.PI/2);
const eL = new THREE.Mesh(eg,mD); eL.position.set(-3,-.1,.35); jet.add(eL);
const eR = new THREE.Mesh(eg.clone(),mD); eR.position.set(-3,-.1,-.35); jet.add(eR);
const gg = new THREE.SphereGeometry(.16,8,8);
const gL = new THREE.Mesh(gg,mGlow); gL.position.set(-3.7,-.1,.35); jet.add(gL);
const gR = new THREE.Mesh(gg.clone(),mGlow); gR.position.set(-3.7,-.1,-.35); jet.add(gR);
const eLight = new THREE.PointLight(0x44ddff,3,10,2); eLight.position.set(-4,-.1,0); jet.add(eLight);
const sr = new THREE.Mesh(new THREE.BoxGeometry(2.5,.04,.06), mR); sr.position.set(.3,.4,0); jet.add(sr);
const sg = new THREE.Mesh(new THREE.BoxGeometry(2.5,.04,.06), mG); sg.position.set(.3,-.3,0); jet.add(sg);
const tl1 = new THREE.Mesh(new THREE.SphereGeometry(.06,6,6), new THREE.MeshBasicMaterial({color:0xff3344}));
tl1.position.set(-2.75,0,4.5); jet.add(tl1);
const tl2 = new THREE.Mesh(new THREE.SphereGeometry(.06,6,6), new THREE.MeshBasicMaterial({color:0x33ff66}));
tl2.position.set(-2.75,0,-4.5); jet.add(tl2);
const belly = new THREE.Mesh(new THREE.BoxGeometry(2,.2,.8), mD);
belly.position.set(0,-.6,0); jet.add(belly);
return jet;
}
const jet = createFighterJet();
jet.position.set(-5, 5, -8);
jet.rotation.y = 0.4;
scene.add(jet);
/* ─── SPACE BUNNY ──────────────────────────────────────────────── */
function createSpaceBunny() {
const bunny = new THREE.Group();
const mFur = new THREE.MeshStandardMaterial({ color:0xf5f0ff, metalness:0, roughness:.8 });
const mSuit = new THREE.MeshStandardMaterial({ color:0xe8e0f0, metalness:.25, roughness:.5 });
const mAcc = new THREE.MeshStandardMaterial({ color:0x33bb66, metalness:.5, roughness:.3 });
const mRed = new THREE.MeshStandardMaterial({ color:0xcc3344, metalness:.4, roughness:.4 });
const mEye = new THREE.MeshStandardMaterial({ color:0x111133, metalness:.3, roughness:.2 });
const mHL = new THREE.MeshBasicMaterial({ color:0xffffff });
const mPink = new THREE.MeshStandardMaterial({ color:0xff88aa, metalness:0, roughness:.6 });
const mVisor = new THREE.MeshPhysicalMaterial({ color:0x88ccff, transparent:true, opacity:.3, roughness:.05, clearcoat:1, side:THREE.DoubleSide });
const mGlow = new THREE.MeshBasicMaterial({ color:0x88ccff });
const mWPack = new THREE.MeshStandardMaterial({ color:0xdddddd, metalness:.4, roughness:.4 });
// Body
const bg = new THREE.SphereGeometry(.5,16,12); bg.scale(.9,1.05,.85);
const body = new THREE.Mesh(bg,mSuit); body.position.y=.55; body.castShadow=true; bunny.add(body);
const bStripe = new THREE.Mesh(new THREE.TorusGeometry(.4,.03,8,16),mAcc);
bStripe.position.y=.6; bStripe.rotation.x=Math.PI/2; bunny.add(bStripe);
// Chest star
const sShape = new THREE.Shape();
for (let i=0;i<5;i++) {
const a=(i*4*Math.PI)/5-Math.PI/2, r=.08;
const x=r*Math.cos(a), y=r*Math.sin(a);
i===0?sShape.moveTo(x,y):sShape.lineTo(x,y);
}
sShape.closePath();
const starM = new THREE.Mesh(
new THREE.ExtrudeGeometry(sShape,{depth:.02,bevelEnabled:false}),
new THREE.MeshBasicMaterial({color:0xffdd44})
);
starM.position.set(0,.7,.42); bunny.add(starM);
// Head
const headG = new THREE.Group(); headG.position.set(0,1.15,.05); bunny.add(headG);
const hg = new THREE.SphereGeometry(.38,16,12); hg.scale(.95,1,.95);
headG.add(new THREE.Mesh(hg,mFur));
const muzzle = new THREE.Mesh(new THREE.SphereGeometry(.18,10,8),mFur);
muzzle.position.set(0,-.1,.3); muzzle.scale.set(1,.8,1); headG.add(muzzle);
const nose = new THREE.Mesh(new THREE.SphereGeometry(.055,8,6),mPink);
nose.position.set(0,-.05,.44); headG.add(nose);
function makeEye(x){
const g=new THREE.Group();
g.add(new THREE.Mesh(new THREE.SphereGeometry(.1,10,8),mEye));
const h1=new THREE.Mesh(new THREE.SphereGeometry(.035,6,6),mHL); h1.position.set(.03,.03,.08); g.add(h1);
const h2=new THREE.Mesh(new THREE.SphereGeometry(.02,6,6),mHL); h2.position.set(-.02,-.02,.08); g.add(h2);
g.position.set(x,.05,.28); return g;
}
headG.add(makeEye(.18)); headG.add(makeEye(-.18));
// Ears
function makeEar(x,dir){
const g=new THREE.Group();
const og=new THREE.SphereGeometry(.15,8,8); og.scale(.5,2.8,1.0);
const outer=new THREE.Mesh(og,mFur); outer.position.y=.4; outer.castShadow=true; g.add(outer);
const ig=new THREE.SphereGeometry(.1,8,8); ig.scale(.3,2.5,.6);
const inner=new THREE.Mesh(ig,mPink); inner.position.set(0,.4,.04); g.add(inner);
const tip=new THREE.Mesh(new THREE.SphereGeometry(.04,6,6),mGlow); tip.position.y=1.05; g.add(tip);
g.position.set(x,.3,0); g.rotation.z=dir*.15; return g;
}
const earL=makeEar(.15,-1); headG.add(earL);
const earR=makeEar(-.15,1); headG.add(earR);
// Visor & helmet rim
const vGeom = new THREE.SphereGeometry(.45,16,12,0,Math.PI*2,0,Math.PI*.55);
const visor = new THREE.Mesh(vGeom,mVisor); visor.position.set(0,.08,.05); headG.add(visor);
const rGeom = new THREE.TorusGeometry(.42,.02,8,24); rGeom.rotateX(Math.PI/2);
const hRim = new THREE.Mesh(rGeom,mAcc); hRim.position.set(0,.08,.05); headG.add(hRim);
// Collar
const cGeom = new THREE.TorusGeometry(.3,.06,8,20); cGeom.rotateX(Math.PI/2);
const collar = new THREE.Mesh(cGeom,mAcc); collar.position.y=.95; bunny.add(collar);
// Jetpack
const pack = new THREE.Mesh(new THREE.CapsuleGeometry(.12,.3,4,8),mWPack);
pack.position.set(0,.6,-.45); bunny.add(pack);
const thruster = new THREE.Mesh(new THREE.SphereGeometry(.06,6,6),mGlow);
thruster.position.set(0,.3,-.45); bunny.add(thruster);
const thrLight = new THREE.PointLight(0x88ccff,1.5,3,2); thrLight.position.set(0,.25,-.45); bunny.add(thrLight);
// Arms
function makeArm(side){
const g=new THREE.Group();
const am=new THREE.Mesh(new THREE.CapsuleGeometry(.07,.25,4,8),mSuit); am.position.y=-.15; g.add(am);
const hand=new THREE.Mesh(new THREE.SphereGeometry(.08,8,6),mFur); hand.position.y=-.35; g.add(hand);
g.position.set(side*.45,.7,.1); g.rotation.z=-side*.3; return g;
}
const armL=makeArm(1); bunny.add(armL);
const armR=makeArm(-1); bunny.add(armR);
// Legs
function makeLeg(side){
const g=new THREE.Group();
const lm=new THREE.Mesh(new THREE.CapsuleGeometry(.1,.2,4,8),mSuit); lm.position.y=-.15; g.add(lm);
const fg=new THREE.SphereGeometry(.12,8,6); fg.scale(.8,.5,1.3);
const foot=new THREE.Mesh(fg,mRed); foot.position.set(0,-.3,.05); g.add(foot);
g.position.set(side*.2,.35,0); return g;
}
const legL=makeLeg(1); bunny.add(legL);
const legR=makeLeg(-1); bunny.add(legR);
return { group:bunny, earL, earR, head:headG, body, armL, armR, legL, legR, thruster, thrLight };
}
const bunny = createSpaceBunny();
scene.add(bunny.group);
bunny.group.position.set(5, 4, 5);
bunny.group.scale.set(1.5, 1.5, 1.5);
/* ─── FLOATING PLATFORMS ───────────────────────────────────────── */
function makePlatform(x, y, z, r) {
const p = new THREE.Mesh(
new THREE.CylinderGeometry(r, r*.75, r*.2, 6),
new THREE.MeshStandardMaterial({ color:0x2a2a4a, metalness:.3, roughness:.6 })
);
p.position.set(x, y, z); p.castShadow = true; scene.add(p);
const ring = new THREE.Mesh(
new THREE.TorusGeometry(r*1.05,.04,6,24),
new THREE.MeshBasicMaterial({ color:0x66aaff })
);
ring.rotation.x = Math.PI/2;
ring.position.set(x, y + r*.1, z);
scene.add(ring);
}
makePlatform(5, 2.8, 5, 1.6);
makePlatform(-3, 1.2, 6, 1.0);
/* ─── "just breathe" TEXT ──────────────────────────────────────── */
const txtCanvas = document.createElement('canvas');
txtCanvas.width = 1024; txtCanvas.height = 256;
const txtCtx = txtCanvas.getContext('2d');
(function drawText() {
txtCtx.clearRect(0,0,1024,256);
txtCtx.font = 'italic 88px Georgia, serif';
txtCtx.textAlign = 'center'; txtCtx.textBaseline = 'middle';
txtCtx.shadowColor = '#8b6cff'; txtCtx.shadowBlur = 50;
txtCtx.fillStyle = 'rgba(140,100,255,0.4)';
txtCtx.fillText('just breathe',512,128);
txtCtx.fillText('just breathe',512,128);
txtCtx.shadowBlur = 25;
txtCtx.fillStyle = 'rgba(180,160,255,0.7)';
txtCtx.fillText('just breathe',512,128);
txtCtx.shadowBlur = 0;
txtCtx.fillStyle = '#e8ddff';
txtCtx.fillText('just breathe',512,128);
})();
const txtTex = new THREE.CanvasTexture(txtCanvas);
txtTex.minFilter = THREE.LinearFilter; txtTex.magFilter = THREE.LinearFilter;
const textMesh = new THREE.Mesh(
new THREE.PlaneGeometry(10, 2.5),
new THREE.MeshBasicMaterial({
map:txtTex, transparent:true, opacity:.9, side:THREE.DoubleSide,
depthWrite:false, blending:THREE.AdditiveBlending
})
);
textMesh.position.set(0, 14, -35);
scene.add(textMesh);
// Light pillar below text
const pillar = new THREE.Mesh(
new THREE.CylinderGeometry(2, 0.5, 14, 8, 1, true),
new THREE.MeshBasicMaterial({
color:0x8b6cff, transparent:true, opacity:.04,
side:THREE.DoubleSide, depthWrite:false, blending:THREE.AdditiveBlending
})
);
pillar.position.set(0, 7, -35);
scene.add(pillar);
/* ─── PARTICLES (space dust) ───────────────────────────────────── */
const PART_COUNT = 400;
const pGeo = new THREE.BufferGeometry();
{
const pp=new Float32Array(PART_COUNT*3), ps=new Float32Array(PART_COUNT), pph=new Float32Array(PART_COUNT);
for(let i=0;i<PART_COUNT;i++){
pp[i*3]=(Math.random()-.5)*80; pp[i*3+1]=Math.random()*30+1; pp[i*3+2]=(Math.random()-.5)*80;
ps[i]=1+Math.random()*3; pph[i]=Math.random()*6.28;
}
pGeo.setAttribute('position',new THREE.BufferAttribute(pp,3));
pGeo.setAttribute('size',new THREE.BufferAttribute(ps,1));
pGeo.setAttribute('phase',new THREE.BufferAttribute(pph,1));
}
const pMat = new THREE.ShaderMaterial({
transparent:true, depthWrite:false, blending:THREE.AdditiveBlending,
uniforms:{uT:{value:0}},
vertexShader:`attribute float size;attribute float phase;uniform float uT;varying float vA;
void main(){vec3 p=position;p.y+=sin(uT*.5+phase)*.5;p.x+=cos(uT*.3+phase)*.3;
vec4 mv=modelViewMatrix*vec4(p,1.);gl_PointSize=size*(180./-mv.z);gl_Position=projectionMatrix*mv;
vA=.3+.3*sin(uT*.8+phase);}`,
fragmentShader:`varying float vA;void main(){float d=length(gl_PointCoord-.5);if(d>.5)discard;
gl_FragColor=vec4(.7,.6,1.,smoothstep(.5,0.,d)*vA);}`
});
scene.add(new THREE.Points(pGeo, pMat));
/* ─── CAMERA ANIMATION ─────────────────────────────────────────── */
function updateCamera(t) {
if (userInteracting) return;
const introEnd = 8;
if (t < introEnd) {
const p = t / introEnd;
const e = 1 - Math.pow(1 - p, 3);
const ang = 0.65 - e * 0.2;
const dist = 48 - e * 26;
const hgt = 15 - e * 8;
camera.position.x = Math.sin(ang) * dist;
camera.position.y = hgt;
camera.position.z = Math.cos(ang) * dist;
camera.lookAt(0, 5 - e * 2, -6 + e * 6);
} else {
const el = t - introEnd;
const ang = 0.45 + Math.sin(el * 0.03) * 0.35;
const dist = 22 + Math.sin(el * 0.1) * 3;
const hgt = 7 + Math.sin(el * 0.08) * 2;
camera.position.x = Math.sin(ang) * dist;
camera.position.y = hgt;
camera.position.z = Math.cos(ang) * dist;
camera.lookAt(
Math.sin(el * 0.06) * 2,
4.5 + Math.sin(el * 0.1) * 1.2,
-2 + Math.cos(el * 0.07) * 2
);
}
camera.position.y += Math.sin(t * 0.35) * 0.3;
}
/* ─── BUNNY ANIMATION ──────────────────────────────────────────── */
const HOP_DUR = 2.0, HOP_H = 1.8;
let hopT = 0, hopIdx = 0;
const path = [
{ x: 5, z: 5, y: 3.8 },
{ x: -3, z: 5, y: 0.5 },
{ x: -2, z: -3, y: 6 },
{ x: 4, z: -1, y: 4.5 },
{ x: 6, z: 4, y: 3.5 },
];
function animBunny(dt, t) {
hopT += dt / HOP_DUR;
if (hopT >= 1) { hopT = 0; hopIdx = (hopIdx + 1) % path.length; }
const from = path[hopIdx];
const to = path[(hopIdx + 1) % path.length];
const p = hopT, e = p * p * (3 - 2 * p);
const x = from.x + (to.x - from.x) * e;
const z = from.z + (to.z - from.z) * e;
const baseY = from.y + (to.y - from.y) * e;
const y = baseY + Math.sin(p * Math.PI) * HOP_H;
// Squash & stretch
let sy=1, sxz=1;
if (p<.12) { const s=p/.12; sy=1-.3*(1-s); sxz=1+.3*(1-s); }
else if (p<.28) { const s=(p-.12)/.16; sy=1+.22*s; sxz=1-.16*s; }
else if (p>.75) { const s=(p-.75)/.25; sy=1-.08*s; sxz=1+.08*s; }
bunny.group.position.set(x, y, z);
bunny.group.scale.set(1.5*sxz, 1.5*sy, 1.5*sxz);
// Face movement
const dx=to.x-from.x, dz=to.z-from.z;
if(Math.abs(dx)>.01||Math.abs(dz)>.01){
const target=Math.atan2(dx,dz);
let diff=target-bunny.group.rotation.y;
while(diff>Math.PI)diff-=Math.PI*2;
while(diff<-Math.PI)diff+=Math.PI*2;
bunny.group.rotation.y+=diff*Math.min(dt*4,1);
}
// Ear wobble (secondary animation)
const ep=t*3, swing=Math.sin(ep)*.15+Math.sin(ep*1.3)*.05, hopSwing=Math.sin(p*Math.PI*2)*.2;
if(bunny.earL){ bunny.earL.rotation.z=-.15+swing+hopSwing; bunny.earL.rotation.x=Math.sin(ep*.8)*.1; }
if(bunny.earR){ bunny.earR.rotation.z=.15-swing-hopSwing; bunny.earR.rotation.x=Math.sin(ep*.8+.5)*.1; }
// Arms
if(bunny.armL){ bunny.armL.rotation.x=Math.sin(ep*1.5)*.3; bunny.armR.rotation.x=Math.sin(ep*1.5+Math.PI)*.3; }
// Legs (tuck in air)
if(bunny.legL){
if(p>.15&&p<.85){ const a=(p-.15)/.7, tuck=Math.sin(a*Math.PI)*.6; bunny.legL.rotation.x=-tuck; bunny.legR.rotation.x=-tuck; }
else { bunny.legL.rotation.x*=.9; bunny.legR.rotation.x*=.9; }
}
// Thruster glow pulse
const tp=.5+.5*Math.sin(t*4);
bunny.thruster.material.opacity=.4+.4*tp;
if(bunny.thrLight) bunny.thrLight.intensity=1+tp*1.5;
// Head idle motion
if(bunny.head){
bunny.head.position.y=1.15+Math.sin(t*2)*.03;
bunny.head.rotation.z=Math.sin(t*1.5)*.03;
bunny.head.rotation.y=Math.sin(t*.7)*.1;
}
}
/* ─── JET / CLOUD ANIMATIONS ───────────────────────────────────── */
function animJet(dt, t) {
jet.position.set(
-5 + Math.sin(t*.3)*.5,
5 + Math.sin(t*.5)*.4,
-8 + Math.cos(t*.2)*.3
);
jet.rotation.z = Math.sin(t*.4)*.05;
jet.rotation.x = Math.sin(t*.3+1)*.03;
jet.rotation.y = 0.4 + Math.sin(t*.15)*.1;
}
function animClouds(dt, t) {
cloudGroup.children.forEach(c => {
c.position.x += c.userData.drift * dt;
c.position.y = c.userData.baseY + Math.sin(t*.15 + c.userData.phase) * 1;
if (c.position.x > 70) c.position.x = -70;
if (c.position.x < -70) c.position.x = 70;
});
}
/* ─── MAIN LOOP ────────────────────────────────────────────────── */
const clock = new THREE.Clock();
let elapsed = 0;
function animate() {
requestAnimationFrame(animate);
const dt = Math.min(clock.getDelta(), 0.05);
elapsed += dt;
waterMat.uniforms.uTime.value = elapsed;
waterMat.uniforms.uCam.value.copy(camera.position);
pMat.uniforms.uT.value = elapsed;
bloom.strength = 0.7 + Math.sin(elapsed * 0.4) * 0.1;
animBunny(dt, elapsed);
animJet(dt, elapsed);
animClouds(dt, elapsed);
updateCamera(elapsed);
// Billboard text toward camera (yaw only)
const dxT = camera.position.x - textMesh.position.x;
const dzT = camera.position.z - textMesh.position.z;
textMesh.rotation.y = Math.atan2(dxT, dzT);
if (controls.enabled) controls.update();
composer.render();
}
/* ─── RESIZE ───────────────────────────────────────────────────── */
addEventListener('resize', () => {
camera.aspect = innerWidth / innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(innerWidth, innerHeight);
composer.setSize(innerWidth, innerHeight);
});
/* ─── START ────────────────────────────────────────────────────── */
animate();
setTimeout(() => document.getElementById('loader').classList.add('done'), 1200);
</script>
</body>
</html>
Timing details
- Actual wait (client)
- 3.40s
- Client preparation
- 0.05s
- Proxy preparation
- 0.00s
- Upstream response headers
- 3.06s
- Upstream first byte
- 3.06s
- Upstream first token
- 3.14s
- Upstream retries
- 0
Actual wait uses the client clock. Upstream timings start after proxy preparation and are cumulative; do not add these values. Upstream delay includes network, scheduling, processing and buffering; it does not prove provider queueing.
Diagnostic receipt
{
"attempts": 1,
"startedAt": "2026-09-29T03:43:55.809Z",
"transport": "railway",
"ttftSource": "proxy",
"clientTtftMs": 3400,
"completionId": "fec2f283-d652-486f-bc90-d7978740a3dc_04858599025247119dde133f5791a086",
"streamResumes": 19,
"proxyPrepareMs": 0,
"proxyRequestId": "cbeFE6DvSROY-BA0ljLL4A",
"upstreamStatus": 200,
"upstreamTtftMs": 3139,
"clientHeadersMs": 259.2999997138977,
"clientPrepareMs": 45.09999990463257,
"upstreamHeadersMs": 3058,
"upstreamFirstByteMs": 3058,
"clientFirstContentMs": 828133.0999999046
}下面是完整的单文件作品。保存为 space-bunny.html 后直接用浏览器打开即可。
- 所有模型、纹理、云层、粒子、UI 均为程序生成。
- 使用真实平面倒影、程序化海浪、动态云层、角色弹簧式耳朵与蹦跳动画。
- 镜头会自动进行缓慢推进和轻微环绕;鼠标移动会带来很轻的视差。
- 唯一外部依赖是通过 CDN 加载的 Three.js,因此首次打开需要联网。
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#090b22" />
<title>Space Bunny — just breathe</title>
<style>
:root {
color-scheme: dark;
--cyan: #bffcff;
--mint: #75f2c0;
--violet: #b39aff;
}
* {
box-sizing: border-box;
}
html,
body {
width: 100%;
height: 100%;
margin: 0;
overflow: hidden;
background: #07091c;
font-family:
Inter, ui-sans-serif, system-ui, -apple-system,
BlinkMacSystemFont, "Segoe UI", sans-serif;
}
#stage,
#stage canvas {
position: fixed;
inset: 0;
width: 100%;
height: 100%;
}
#stage canvas {
display: block;
outline: none;
}
#vignette,
#grain {
position: fixed;
inset: 0;
z-index: 3;
pointer-events: none;
}
#vignette {
background:
radial-gradient(
ellipse at 50% 45%,
transparent 40%,
rgba(4, 5, 20, 0.12) 68%,
rgba(3, 4, 17, 0.62) 100%
),
linear-gradient(
to bottom,
rgba(4, 5, 18, 0.2),
transparent 17%,
transparent 78%,
rgba(3, 5, 18, 0.38)
);
}
#grain {
opacity: 0.055;
mix-blend-mode: soft-light;
background-image:
radial-gradient(circle, rgba(255,255,255,.7) 0.5px, transparent 0.8px),
radial-gradient(circle, rgba(120,190,255,.5) 0.5px, transparent 0.8px);
background-position: 0 0, 2px 3px;
background-size: 4px 4px, 5px 5px;
}
.hud {
position: fixed;
inset: 0;
z-index: 5;
pointer-events: none;
color: rgba(226, 246, 255, 0.78);
}
.brand {
position: absolute;
top: 28px;
left: 32px;
display: flex;
align-items: center;
gap: 13px;
letter-spacing: 0.16em;
text-transform: uppercase;
user-select: none;
}
.emblem {
position: relative;
width: 34px;
height: 34px;
border: 1px solid rgba(185, 255, 242, 0.48);
border-radius: 50%;
box-shadow:
0 0 22px rgba(111, 240, 208, 0.12),
inset 0 0 12px rgba(140, 218, 255, 0.08);
}
.emblem::before,
.emblem::after {
content: "";
position: absolute;
top: -7px;
width: 7px;
height: 17px;
border: 1px solid rgba(185, 255, 242, 0.68);
border-radius: 60% 60% 35% 35%;
background: rgba(92, 128, 178, 0.14);
}
.emblem::before {
left: 8px;
transform: rotate(-8deg);
}
.emblem::after {
right: 8px;
transform: rotate(8deg);
}
.brand strong {
display: block;
font-size: 11px;
font-weight: 600;
}
.brand small {
display: block;
margin-top: 5px;
color: rgba(184, 211, 230, 0.48);
font-size: 8px;
letter-spacing: 0.22em;
}
.telemetry {
position: absolute;
right: 31px;
bottom: 27px;
display: flex;
align-items: center;
gap: 11px;
font-size: 9px;
letter-spacing: 0.17em;
text-transform: uppercase;
}
.telemetry .signal {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--mint);
box-shadow: 0 0 12px var(--mint);
animation: breathe 3.8s ease-in-out infinite;
}
.caption {
position: absolute;
left: 32px;
bottom: 27px;
max-width: 260px;
color: rgba(204, 225, 239, 0.42);
font-size: 8px;
line-height: 1.7;
letter-spacing: 0.18em;
text-transform: uppercase;
}
#pauseButton {
position: absolute;
top: 26px;
right: 30px;
min-width: 78px;
padding: 9px 13px;
border: 1px solid rgba(191, 234, 255, 0.18);
border-radius: 999px;
outline: none;
color: rgba(218, 243, 255, 0.68);
background: rgba(10, 14, 37, 0.22);
backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px);
font: inherit;
font-size: 8px;
letter-spacing: 0.17em;
text-transform: uppercase;
cursor: pointer;
pointer-events: auto;
transition:
border-color 180ms ease,
color 180ms ease,
background 180ms ease;
}
#pauseButton:hover,
#pauseButton:focus-visible {
color: #dfffff;
border-color: rgba(132, 255, 224, 0.55);
background: rgba(29, 58, 69, 0.32);
}
#loading {
position: fixed;
inset: 0;
z-index: 20;
display: grid;
place-items: center;
color: rgba(222, 244, 255, 0.86);
background:
radial-gradient(circle at 50% 45%, #242154 0%, #0a0c22 48%, #050617 100%);
transition:
opacity 1.2s cubic-bezier(.2,.8,.2,1),
visibility 1.2s;
}
#loading .loader {
position: relative;
width: 76px;
height: 76px;
margin: 0 auto 23px;
border: 1px solid rgba(174, 245, 255, 0.2);
border-radius: 50%;
box-shadow: inset 0 0 35px rgba(104, 205, 255, 0.06);
animation: orbit 5s linear infinite;
}
#loading .loader::before,
#loading .loader::after {
content: "";
position: absolute;
top: 13px;
width: 12px;
height: 31px;
border: 1px solid rgba(202, 255, 247, 0.65);
border-radius: 70% 70% 35% 35%;
background: rgba(118, 172, 224, 0.1);
}
#loading .loader::before {
left: 18px;
transform: rotate(-8deg);
}
#loading .loader::after {
right: 18px;
transform: rotate(8deg);
}
#loading .loader i {
position: absolute;
inset: 26px;
border-radius: 50%;
background: rgba(135, 255, 223, 0.75);
box-shadow: 0 0 18px rgba(100, 255, 216, 0.7);
}
#loading strong {
display: block;
font-size: 10px;
font-weight: 500;
letter-spacing: 0.32em;
text-align: center;
text-transform: uppercase;
}
#loading small {
display: block;
margin-top: 10px;
color: rgba(184, 210, 231, 0.42);
font-size: 8px;
letter-spacing: 0.2em;
text-align: center;
}
body.loaded #loading {
opacity: 0;
visibility: hidden;
}
@keyframes orbit {
to { transform: rotate(360deg); }
}
@keyframes breathe {
0%, 100% {
opacity: 0.45;
transform: scale(0.8);
}
50% {
opacity: 1;
transform: scale(1.2);
}
}
@media (max-width: 700px) {
.brand {
top: 20px;
left: 20px;
}
#pauseButton {
top: 18px;
right: 18px;
}
.caption {
left: 20px;
bottom: 20px;
max-width: 180px;
}
.telemetry {
right: 20px;
bottom: 20px;
}
}
@media (prefers-reduced-motion: reduce) {
.telemetry .signal,
#loading .loader {
animation-duration: 10s;
}
}
</style>
</head>
<body>
<div id="stage"></div>
<div id="vignette"></div>
<div id="grain"></div>
<div class="hud">
<div class="brand">
<div class="emblem"></div>
<div>
<strong>Space Bunny</strong>
<small>Dream Coast / Flight 07</small>
</div>
</div>
<div class="caption">
Low-orbit living unit<br />
Move gently. The sky is listening.
</div>
<div class="telemetry">
<span class="signal"></span>
<span id="timecode">T+ 00:00</span>
</div>
<button id="pauseButton" type="button" aria-label="暂停动画">
Pause Ⅱ
</button>
</div>
<div id="loading">
<div>
<div class="loader"><i></i></div>
<strong id="loadingText">Entering the dream coast</strong>
<small>All destinations are made of breathing</small>
</div>
</div>
<!-- 唯一外部依赖:Three.js;其余内容全部在当前文件内生成。 -->
<script src="https://cdn.jsdelivr.net/npm/three@0.158.0/build/three.min.js"></script>
<script>
(() => {
"use strict";
const loading = document.getElementById("loading");
const loadingText = document.getElementById("loadingText");
if (!window.THREE) {
loadingText.textContent = "Three.js could not be loaded";
return;
}
const stage = document.getElementById("stage");
const pauseButton = document.getElementById("pauseButton");
const timecode = document.getElementById("timecode");
const TAU = Math.PI * 2;
const V2 = THREE.Vector2;
const V3 = THREE.Vector3;
const reducedMotion = matchMedia("(prefers-reduced-motion: reduce)").matches;
// ------------------------------------------------------------
// 渲染器与基础场景
// ------------------------------------------------------------
let renderer;
try {
renderer = new THREE.WebGLRenderer({
antialias: true,
alpha: false,
powerPreference: "high-performance"
});
} catch (error) {
loadingText.textContent = "WebGL is not available in this browser";
return;
}
renderer.setPixelRatio(
Math.min(devicePixelRatio, innerWidth < 700 ? 1.4 : 1.75)
);
renderer.setSize(innerWidth, innerHeight);
renderer.outputColorSpace = THREE.SRGBColorSpace;
renderer.toneMapping = THREE.ACESFilmicToneMapping;
renderer.toneMappingExposure = 1.13;
renderer.shadowMap.enabled = true;
renderer.shadowMap.type = THREE.PCFSoftShadowMap;
renderer.setClearColor(0x07091c, 1);
renderer.domElement.setAttribute("aria-label", "Space Bunny 3D artwork");
stage.appendChild(renderer.domElement);
const scene = new THREE.Scene();
scene.fog = new THREE.FogExp2(0x171731, 0.0125);
const camera = new THREE.PerspectiveCamera(
innerWidth < 700 ? 51 : 43,
innerWidth / innerHeight,
0.1,
220
);
// 第 1 层用于只出现在主镜头里的近景散景。
camera.layers.enable(1);
camera.position.set(5.5, 4.5, 16.4);
const cameraLook = new THREE.Vector3(-0.35, 2.45, -0.9);
const desiredCamera = new THREE.Vector3();
const desiredLook = new THREE.Vector3();
let seed = 74291;
function random() {
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;
}
function clamp01(v) {
return Math.max(0, Math.min(1, v));
}
function smooth(v) {
v = clamp01(v);
return v * v * (3 - 2 * v);
}
function addMesh(
parent,
geometry,
material,
position = [0, 0, 0],
scale = [1, 1, 1],
castShadow = true
) {
const mesh = new THREE.Mesh(geometry, material);
mesh.position.set(...position);
mesh.scale.set(...scale);
mesh.castShadow = castShadow;
mesh.receiveShadow = true;
parent.add(mesh);
return mesh;
}
function makeGlowTexture() {
const canvas = document.createElement("canvas");
canvas.width = canvas.height = 128;
const ctx = canvas.getContext("2d");
const gradient = ctx.createRadialGradient(64, 64, 0, 64, 64, 64);
gradient.addColorStop(0, "rgba(255,255,255,1)");
gradient.addColorStop(0.13, "rgba(255,255,255,.82)");
gradient.addColorStop(0.42, "rgba(255,255,255,.23)");
gradient.addColorStop(1, "rgba(255,255,255,0)");
ctx.fillStyle = gradient;
ctx.fillRect(0, 0, 128, 128);
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
return texture;
}
function makeTextTexture(text, subtitle = "") {
const canvas = document.createElement("canvas");
canvas.width = 1024;
canvas.height = 256;
const ctx = canvas.getContext("2d");
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.textAlign = "center";
ctx.textBaseline = "middle";
ctx.font = "italic 500 76px Georgia, serif";
ctx.shadowColor = "rgba(120,255,235,.95)";
ctx.shadowBlur = 24;
ctx.fillStyle = "#d9ffff";
ctx.fillText(text, 512, 110);
ctx.shadowBlur = 8;
ctx.fillStyle = "rgba(240,255,255,.92)";
ctx.fillText(text, 512, 110);
if (subtitle) {
ctx.shadowBlur = 0;
ctx.font = "500 18px Arial, sans-serif";
ctx.letterSpacing = "5px";
ctx.fillStyle = "rgba(180,225,235,.62)";
ctx.fillText(subtitle, 512, 183);
}
const texture = new THREE.CanvasTexture(canvas);
texture.colorSpace = THREE.SRGBColorSpace;
texture.minFilter = THREE.LinearFilter;
texture.magFilter = THREE.LinearFilter;
texture.generateMipmaps = false;
return texture;
}
const glowTexture = makeGlowTexture();
// ------------------------------------------------------------
// 深蓝到紫色的渐变天空
// ------------------------------------------------------------
const sky = new THREE.Mesh(
new THREE.SphereGeometry(110, 48, 28),
new THREE.ShaderMaterial({
side: THREE.BackSide,
depthWrite: false,
uniforms: {
uTime: { value: 0 }
},
vertexShader: `
varying vec3 vDirection;
void main() {
vDirection = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
precision highp float;
uniform float uTime;
varying vec3 vDirection;
float hash(vec2 p) {
return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453);
}
void main() {
vec3 d = normalize(vDirection);
float h = clamp(d.y * 0.5 + 0.5, 0.0, 1.0);
vec3 horizon = vec3(0.050, 0.065, 0.205);
vec3 middle = vec3(0.075, 0.040, 0.210);
vec3 zenith = vec3(0.010, 0.008, 0.070);
vec3 color = mix(horizon, middle, smoothstep(0.20, 0.54, h));
color = mix(color, zenith, smoothstep(0.55, 1.0, h));
float horizonGlow = pow(1.0 - abs(d.y), 5.0);
color += vec3(0.16, 0.10, 0.25) * horizonGlow * 0.5;
float veil =
exp(-pow((h - 0.46) * 7.0, 2.0)) *
(0.5 + 0.5 * sin(d.x * 17.0 + d.z * 9.0 + uTime * 0.025));
color += vec3(0.13, 0.035, 0.19) * veil * 0.22;
float grain = hash(gl_FragCoord.xy) - 0.5;
color += grain * 0.004;
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`
})
);
sky.renderOrder = -20;
scene.add(sky);
// ------------------------------------------------------------
// 星星、星云与远方珍珠月
// ------------------------------------------------------------
const starCount = innerWidth < 700 ? 650 : 1050;
const starPositions = new Float32Array(starCount * 3);
const starSizes = new Float32Array(starCount);
const starPhases = new Float32Array(starCount);
for (let i = 0; i < starCount; i++) {
const theta = random() * TAU;
const y = 0.03 + random() * 0.94;
const radius = 73 + random() * 27;
const horizontal = Math.sqrt(1 - y * y);
starPositions[i * 3] = Math.cos(theta) * horizontal * radius;
starPositions[i * 3 + 1] = y * radius;
starPositions[i * 3 + 2] = Math.sin(theta) * horizontal * radius;
starSizes[i] = 0.75 + random() * 2.0;
starPhases[i] = random() * TAU;
}
const starGeometry = new THREE.BufferGeometry();
starGeometry.setAttribute("position", new THREE.BufferAttribute(starPositions, 3));
starGeometry.setAttribute("aSize", new THREE.BufferAttribute(starSizes, 1));
starGeometry.setAttribute("aPhase", new THREE.BufferAttribute(starPhases, 1));
const stars = new THREE.Points(
starGeometry,
new THREE.ShaderMaterial({
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uTime: { value: 0 }
},
vertexShader: `
uniform float uTime;
attribute float aSize;
attribute float aPhase;
varying float vAlpha;
void main() {
float twinkle = 0.62 + 0.38 * sin(uTime * 0.8 + aPhase);
vAlpha = twinkle;
gl_PointSize = aSize * (0.86 + twinkle * 0.24);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
varying float vAlpha;
void main() {
vec2 p = gl_PointCoord - 0.5;
float d = length(p);
float alpha = smoothstep(0.5, 0.0, d) * vAlpha;
vec3 color = mix(vec3(0.50,0.69,1.0), vec3(1.0,0.88,0.75), vAlpha);
gl_FragColor = vec4(color, alpha * 0.85);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`
})
);
stars.renderOrder = -10;
scene.add(stars);
function addNebula(position, scale, color, opacity) {
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: glowTexture,
color,
transparent: true,
opacity,
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false
})
);
sprite.position.set(...position);
sprite.scale.set(scale, scale * 0.62, 1);
sprite.renderOrder = -8;
scene.add(sprite);
return sprite;
}
addNebula([-21, 15, -49], 34, 0x8758ff, 0.16);
addNebula([25, 18, -57], 40, 0x537fff, 0.10);
addNebula([2, 5, -65], 48, 0xc772ff, 0.08);
const moon = new THREE.Mesh(
new THREE.SphereGeometry(3.55, 48, 32),
new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 }
},
vertexShader: `
varying vec3 vNormal;
varying vec3 vPoint;
void main() {
vNormal = normalize(normal);
vPoint = position;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`,
fragmentShader: `
precision highp float;
uniform float uTime;
varying vec3 vNormal;
varying vec3 vPoint;
void main() {
vec3 n = normalize(vNormal);
vec3 lightDirection = normalize(vec3(-0.5, 0.65, 0.8));
float light = 0.32 + 0.68 * max(dot(n, lightDirection), 0.0);
float terrain =
sin(vPoint.x * 7.0 + sin(vPoint.z * 3.0)) *
sin(vPoint.y * 9.0 - vPoint.x * 2.0);
float craters =
smoothstep(0.67, 0.96, sin(vPoint.x * 19.0) * sin(vPoint.z * 17.0));
vec3 base = mix(
vec3(0.46, 0.51, 0.80),
vec3(0.80, 0.84, 1.0),
light
);
base -= terrain * 0.035;
base -= craters * 0.06;
float rim = pow(1.0 - max(dot(n, vec3(0.0, 0.0, 1.0)), 0.0), 3.0);
base += vec3(0.22, 0.18, 0.40) * rim * 0.16;
gl_FragColor = vec4(base, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`
})
);
moon.position.set(12, 12.3, -47);
scene.add(moon);
const moonHalo = new THREE.Sprite(
new THREE.SpriteMaterial({
map: glowTexture,
color: 0x9aa6ff,
transparent: true,
opacity: 0.30,
depthWrite: false,
blending: THREE.AdditiveBlending,
fog: false
})
);
moonHalo.position.copy(moon.position);
moonHalo.scale.set(15, 15, 1);
scene.add(moonHalo);
// ------------------------------------------------------------
// 灯光:珍珠月光、冷色主光与紫色轮廓光
// ------------------------------------------------------------
scene.add(new THREE.HemisphereLight(0x9ebcff, 0x17122d, 1.35));
const keyLight = new THREE.DirectionalLight(0xc7dcff, 2.8);
keyLight.position.set(7, 14, 10);
keyLight.castShadow = true;
keyLight.shadow.mapSize.set(1024, 1024);
keyLight.shadow.camera.left = -14;
keyLight.shadow.camera.right = 14;
keyLight.shadow.camera.top = 12;
keyLight.shadow.camera.bottom = -8;
keyLight.shadow.camera.near = 1;
keyLight.shadow.camera.far = 50;
keyLight.shadow.bias = -0.0005;
scene.add(keyLight);
scene.add(keyLight.target);
const rimLight = new THREE.DirectionalLight(0xc178ff, 1.55);
rimLight.position.set(-11, 6, -12);
scene.add(rimLight);
const cyanFill = new THREE.DirectionalLight(0x74ffe0, 0.72);
cyanFill.position.set(-8, 4, 12);
scene.add(cyanFill);
// ------------------------------------------------------------
// 具有体积感的紫色积云
// ------------------------------------------------------------
const cloudGeometry = new THREE.SphereGeometry(1, 18, 13);
const cloudMaterials = [
new THREE.MeshStandardMaterial({
color: 0x9c78dc,
roughness: 0.94,
metalness: 0,
emissive: 0x24104a,
emissiveIntensity: 0.34
}),
new THREE.MeshStandardMaterial({
color: 0xb091eb,
roughness: 0.96,
metalness: 0,
emissive: 0x35205c,
emissiveIntensity: 0.28
}),
new THREE.MeshStandardMaterial({
color: 0x7657b8,
roughness: 0.98,
metalness: 0,
emissive: 0x180b3c,
emissiveIntensity: 0.38
})
];
const clouds = [];
function createCloud(position, scale, phase) {
const group = new THREE.Group();
group.position.set(...position);
group.scale.set(scale, scale * 0.72, scale);
group.userData.base = group.position.clone();
group.userData.phase = phase;
group.userData.speed = 0.025 + random() * 0.035;
const lobeCount = 9;
for (let i = 0; i < lobeCount; i++) {
const angle = (i / lobeCount) * TAU + random() * 0.3;
const radius = random() * 1.15;
const x = Math.cos(angle) * radius;
const z = Math.sin(angle) * radius * 0.55;
const y = random() * 0.65;
const sx = 1.05 + random() * 1.25;
const sy = 0.65 + random() * 0.8;
const sz = 0.8 + random() * 0.95;
const mat = i === 0
? cloudMaterials[2]
: i % 3 === 0
? cloudMaterials[1]
: cloudMaterials[0];
addMesh(
group,
cloudGeometry,
mat,
[x, y, z],
[sx, sy, sz],
false
);
}
addMesh(
group,
cloudGeometry,
cloudMaterials[2],
[0, -0.22, 0],
[2.7, 0.65, 1.4],
false
);
scene.add(group);
clouds.push(group);
return group;
}
createCloud([-20, 10, -31], 5.6, 0.1);
createCloud([-7, 16, -48], 7.3, 1.4);
createCloud([19, 13, -38], 6.5, 2.2);
createCloud([27, 7.3, -13], 4.3, 3.3);
createCloud([-15, 7.3, -7], 3.4, 4.0);
createCloud([1, 19, -66], 8.4, 5.2);
// 远处的漂浮海岸岩层。
const rockGeometry = new THREE.DodecahedronGeometry(1, 0);
const rockMaterial = new THREE.MeshStandardMaterial({
color: 0x30264e,
roughness: 0.92,
metalness: 0.06,
emissive: 0x120c28,
emissiveIntensity: 0.32
});
const coastRockGroups = [];
function createOutcrop(position, scale, phase) {
const group = new THREE.Group();
group.position.set(...position);
group.scale.setScalar(scale);
group.userData.base = group.position.clone();
group.userData.phase = phase;
for (let i = 0; i < 6; i++) {
const angle = random() * TAU;
const radius = random() * 1.45;
addMesh(
group,
rockGeometry,
rockMaterial,
[
Math.cos(angle) * radius,
-0.1 + random() * 0.45,
Math.sin(angle) * radius * 0.55
],
[
0.8 + random(),
0.45 + random() * 0.8,
0.7 + random()
],
false
);
}
const halo = addMesh(
group,
new THREE.TorusGeometry(1.75, 0.025, 6, 64),
new THREE.MeshBasicMaterial({
color: 0x7a6aff,
transparent: true,
opacity: 0.24,
blending: THREE.AdditiveBlending,
depthWrite: false
}),
[0, 0.22, 0],
[1, 1, 1],
false
);
halo.rotation.x = Math.PI / 2;
scene.add(group);
coastRockGroups.push(group);
}
createOutcrop([-15, 0.15, -14], 2.1, 0.2);
createOutcrop([19, 0.05, -23], 1.6, 2.1);
createOutcrop([-27, -0.05, -35], 2.8, 4.2);
// ------------------------------------------------------------
// 主体:白、绿、红未来战斗机
// ------------------------------------------------------------
const paintWhite = new THREE.MeshPhysicalMaterial({
color: 0xf1f3ff,
roughness: 0.27,
metalness: 0.12,
clearcoat: 0.72,
clearcoatRoughness: 0.18
});
const paintGreen = new THREE.MeshPhysicalMaterial({
color: 0x52dba1,
roughness: 0.32,
metalness: 0.18,
clearcoat: 0.7
});
const paintRed = new THREE.MeshPhysicalMaterial({
color: 0xf24b63,
roughness: 0.34,
metalness: 0.12,
clearcoat: 0.62
});
const paintDark = new THREE.MeshStandardMaterial({
color: 0x11172c,
roughness: 0.31,
metalness: 0.72
});
const cockpitMaterial = new THREE.MeshPhysicalMaterial({
color: 0x132546,
roughness: 0.08,
metalness: 0.38,
transparent: true,
opacity: 0.9,
clearcoat: 1,
clearcoatRoughness: 0.05
});
const cyanGlow = new THREE.MeshBasicMaterial({
color: 0x9efff0,
toneMapped: false
});
const wingMaterial = paintWhite.clone();
wingMaterial.side = THREE.DoubleSide;
const wingGreenMaterial = paintGreen.clone();
wingGreenMaterial.side = THREE.DoubleSide;
const wingRedMaterial = paintRed.clone();
wingRedMaterial.side = THREE.DoubleSide;
const fighter = new THREE.Group();
fighter.position.set(1.15, 3.25, -2.65);
fighter.rotation.set(-0.015, -0.08, 0.012);
scene.add(fighter);
const fuselageProfile = [
new V2(0.00, -3.30),
new V2(0.25, -3.08),
new V2(0.43, -2.72),
new V2(0.58, -2.10),
new V2(0.65, -0.80),
new V2(0.66, 0.45),
new V2(0.59, 1.35),
new V2(0.48, 2.15),
new V2(0.28, 2.65),
new V2(0.00, 2.88)
];
const fuselageGeometry = new THREE.LatheGeometry(fuselageProfile, 56);
fuselageGeometry.rotateZ(-Math.PI / 2);
addMesh(fighter, fuselageGeometry, paintWhite);
addMesh(
fighter,
new THREE.SphereGeometry(1, 32, 20),
paintGreen,
[-0.15, -0.43, 0],
[2.75, 0.32, 0.57]
);
addMesh(
fighter,
new THREE.BoxGeometry(2.6, 0.055, 0.12),
paintRed,
[-0.25, 0.62, 0]
);
function makeShape(points) {
const shape = new THREE.Shape();
shape.moveTo(points[0][0], points[0][1]);
for (let i = 1; i < points.length; i++) {
shape.lineTo(points[i][0], points[i][1]);
}
shape.closePath();
return shape;
}
function horizontalExtrusion(points, depth, bevel = 0.025) {
const geometry = new THREE.ExtrudeGeometry(makeShape(points), {
depth,
steps: 1,
bevelEnabled: bevel > 0,
bevelSegments: 2,
bevelSize: bevel,
bevelThickness: bevel
});
// Shape 的第二维作为世界 Z,厚度成为世界 Y。
geometry.rotateX(Math.PI / 2);
return geometry;
}
function makeWing(side) {
const wingPoints = [
[1.72, 0.24],
[-0.16, 0.94],
[-2.62, 3.00],
[-2.23, 3.30],
[0.18, 1.27],
[-1.78, 0.23]
].map(([x, z]) => [x, z * side]);
const group = new THREE.Group();
addMesh(
group,
horizontalExtrusion(wingPoints, 0.13, 0.035),
wingMaterial,
[0, 0.085, 0]
);
const panelPoints = [
[0.95, 0.43],
[-0.10, 0.98],
[-1.72, 2.45],
[-0.90, 1.30],
[-0.65, 0.54]
].map(([x, z]) => [x, z * side]);
addMesh(
group,
horizontalExtrusion(panelPoints, 0.026, 0.012),
wingGreenMaterial,
[0, 0.122, 0],
[1, 1, 1],
false
);
const tipPoints = [
[-1.95, 2.65],
[-2.62, 3.00],
[-2.23, 3.30],
[-1.73, 3.08]
].map(([x, z]) => [x, z * side]);
addMesh(
group,
horizontalExtrusion(tipPoints, 0.032, 0.01),
wingRedMaterial,
[0, 0.128, 0],
[1, 1, 1],
false
);
return group;
}
fighter.add(makeWing(1));
fighter.add(makeWing(-1));
// 座舱。
addMesh(
fighter,
new THREE.SphereGeometry(1, 36, 22),
cockpitMaterial,
[1.18, 0.48, 0],
[0.91, 0.43, 0.47]
);
addMesh(
fighter,
new THREE.SphereGeometry(1, 24, 14),
new THREE.MeshBasicMaterial({
color: 0x8ebaff,
transparent: true,
opacity: 0.25,
depthWrite: false
}),
[1.45, 0.70, 0.20],
[0.42, 0.045, 0.11],
false
);
const frameRing = addMesh(
fighter,
new THREE.TorusGeometry(0.49, 0.026, 8, 40),
paintDark,
[1.12, 0.48, 0]
);
frameRing.rotation.y = Math.PI / 2;
frameRing.scale.set(1, 0.80, 1);
// 机身分段环。
[-2.1, -0.65, 0.35].forEach((x, index) => {
const ring = addMesh(
fighter,
new THREE.TorusGeometry(0.62, index === 1 ? 0.045 : 0.018, 8, 48),
index === 1 ? paintRed : paintDark,
[x, 0, 0]
);
ring.rotation.y = Math.PI / 2;
});
// 垂直尾翼。
const finShape = new THREE.Shape();
finShape.moveTo(-1.80, 0.12);
finShape.lineTo(-2.55, 1.40);
finShape.lineTo(-3.12, 1.48);
finShape.lineTo(-3.34, 0.16);
finShape.closePath();
const fin = addMesh(
fighter,
new THREE.ExtrudeGeometry(finShape, {
depth: 0.12,
bevelEnabled: true,
bevelSize: 0.025,
bevelThickness: 0.02,
bevelSegments: 2
}),
wingMaterial,
[0, 0, -0.06]
);
fin.material.side = THREE.DoubleSide;
const finPanel = new THREE.Shape();
finPanel.moveTo(-2.37, 0.47);
finPanel.lineTo(-2.72, 1.16);
finPanel.lineTo(-3.02, 1.15);
finPanel.lineTo(-2.95, 0.45);
finPanel.closePath();
addMesh(
fighter,
new THREE.ShapeGeometry(finPanel),
wingGreenMaterial,
[0, 0, 0.086],
[1, 1, 1],
false
);
// 水平尾翼。
[-1, 1].forEach(side => {
const points = [
[-2.35, 0.17],
[-3.13, 0.80],
[-3.48, 0.75],
[-3.28, 0.14]
].map(([x, z]) => [x, z * side]);
addMesh(
fighter,
horizontalExtrusion(points, 0.075, 0.018),
wingMaterial,
[0, 0.03, 0]
);
});
// 机腹炮与进气口。
[-0.29, 0.29].forEach(z => {
const cannon = addMesh(
fighter,
new THREE.CylinderGeometry(0.065, 0.085, 1.35, 14),
paintDark,
[2.15, -0.36, z]
);
cannon.rotation.z = Math.PI / 2;
const intake = addMesh(
fighter,
new THREE.CylinderGeometry(0.23, 0.30, 1.05, 20),
paintDark,
[-2.12, -0.06, z]
);
intake.rotation.z = Math.PI / 2;
});
const fighterDecal = new THREE.Mesh(
new THREE.PlaneGeometry(1.18, 0.31),
new THREE.MeshBasicMaterial({
map: makeTextTexture("SB—01", "DREAMER"),
transparent: true,
depthWrite: false,
polygonOffset: true,
polygonOffsetFactor: -2
})
);
fighterDecal.position.set(-0.22, 0.17, 0.657);
fighter.add(fighterDecal);
// 发动机、尾焰与航行灯。
const fighterFlames = [];
const fighterThrusterLights = [];
[-0.38, 0.38].forEach(z => {
const engine = addMesh(
fighter,
new THREE.CylinderGeometry(0.24, 0.31, 0.95, 24),
paintDark,
[-3.13, -0.03, z]
);
engine.rotation.z = Math.PI / 2;
const rim = addMesh(
fighter,
new THREE.TorusGeometry(0.265, 0.035, 8, 28),
paintGreen,
[-3.58, -0.03, z]
);
rim.rotation.y = Math.PI / 2;
const glowDisc = addMesh(
fighter,
new THREE.CircleGeometry(0.20, 28),
cyanGlow,
[-3.61, -0.03, z],
[1, 1, 1],
false
);
glowDisc.rotation.y = -Math.PI / 2;
glowDisc.userData.noShadow = true;
const flameMaterial = new THREE.MeshBasicMaterial({
color: z > 0 ? 0x8efff0 : 0xc2a8ff,
transparent: true,
opacity: 0.44,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
toneMapped: false
});
const flame = addMesh(
fighter,
new THREE.ConeGeometry(0.17, 1.35, 18, 1, true),
flameMaterial,
[-4.18, -0.03, z],
[1, 1, 1],
false
);
flame.rotation.z = Math.PI / 2;
flame.userData.noShadow = true;
fighterFlames.push(flame);
const light = new THREE.PointLight(0x7dffe5, 7, 7, 2);
light.position.set(-3.8, 0, z);
fighter.add(light);
fighterThrusterLights.push(light);
});
const navGreen = new THREE.MeshBasicMaterial({ color: 0x75ffbc, toneMapped: false });
const navRed = new THREE.MeshBasicMaterial({ color: 0xff5872, toneMapped: false });
addMesh(
fighter,
new THREE.SphereGeometry(0.085, 12, 8),
navRed,
[-2.38, 0.14, -3.15],
[1, 1, 1],
false
);
addMesh(
fighter,
new THREE.SphereGeometry(0.085, 12, 8),
navGreen,
[-2.38, 0.14, 3.15],
[1, 1, 1],
false
);
// 发动机尾迹粒子。
const exhaustCount = 90;
const exhaustPositions = new Float32Array(exhaustCount * 3);
const exhaustData = [];
for (let i = 0; i < exhaustCount; i++) {
exhaustData.push({
seed: random(),
lane: i % 2 === 0 ? -0.38 : 0.38,
drift: random() - 0.5
});
}
const exhaustGeometry = new THREE.BufferGeometry();
exhaustGeometry.setAttribute(
"position",
new THREE.BufferAttribute(exhaustPositions, 3).setUsage(THREE.DynamicDrawUsage)
);
const exhaust = new THREE.Points(
exhaustGeometry,
new THREE.PointsMaterial({
color: 0x8fffea,
size: 0.085,
transparent: true,
opacity: 0.68,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true
})
);
fighter.add(exhaust);
// ------------------------------------------------------------
// Space Bunny 主角
// 白色兔头 + 飞行夹克 + 红色围巾 + 飞行目镜 +
// 薄荷绿装备 + 小型喷气背包。
// ------------------------------------------------------------
const furMaterial = new THREE.MeshStandardMaterial({
color: 0xf5f2ff,
roughness: 0.88,
metalness: 0,
emissive: 0x17152b,
emissiveIntensity: 0.08
});
const suitMaterial = new THREE.MeshPhysicalMaterial({
color: 0xe9ecfb,
roughness: 0.38,
metalness: 0.08,
clearcoat: 0.55
});
const bunnyPink = new THREE.MeshStandardMaterial({
color: 0xf0a4cb,
roughness: 0.86,
emissive: 0x441a39,
emissiveIntensity: 0.10
});
const scarfMaterial = new THREE.MeshPhysicalMaterial({
color: 0xf04464,
roughness: 0.62,
clearcoat: 0.25,
side: THREE.DoubleSide
});
const visorMaterial = new THREE.MeshPhysicalMaterial({
color: 0x101b36,
roughness: 0.08,
metalness: 0.55,
clearcoat: 1,
clearcoatRoughness: 0.04
});
const eyeMaterial = new THREE.MeshBasicMaterial({
color: 0xa8fff0,
toneMapped: false
});
const unitSphere = new THREE.SphereGeometry(1, 24, 16);
function addSphere(parent, material, position, scale, shadow = true) {
return addMesh(parent, unitSphere, material, position, scale, shadow);
}
const bunnyRoot = new THREE.Group();
bunnyRoot.scale.setScalar(0.82);
scene.add(bunnyRoot);
const bunnyModel = new THREE.Group();
bunnyRoot.add(bunnyModel);
const bunnyUpper = new THREE.Group();
bunnyModel.add(bunnyUpper);
addSphere(
bunnyUpper,
suitMaterial,
[0, 1.18, 0],
[0.51, 0.61, 0.43]
);
addSphere(
bunnyUpper,
furMaterial,
[0.40, 1.16, 0],
[0.11, 0.40, 0.31]
);
addSphere(
bunnyUpper,
paintGreen,
[-0.38, 1.20, 0.16],
[0.19, 0.46, 0.19]
);
addSphere(
bunnyUpper,
paintGreen,
[-0.38, 1.20, -0.16],
[0.19, 0.46, 0.19]
);
const collar = addMesh(
bunnyUpper,
new THREE.TorusGeometry(0.34, 0.065, 8, 28),
paintGreen,
[0, 1.61, 0]
);
collar.rotation.x = Math.PI / 2;
const belt = addMesh(
bunnyUpper,
new THREE.TorusGeometry(0.42, 0.045, 8, 28),
paintRed,
[0, 0.95, 0]
);
belt.rotation.x = Math.PI / 2;
belt.scale.set(1.0, 1.0, 0.84);
const chestBadge = addMesh(
bunnyUpper,
new THREE.CylinderGeometry(0.105, 0.105, 0.025, 20),
paintRed,
[0.49, 1.40, 0.10]
);
chestBadge.rotation.z = Math.PI / 2;
addMesh(
bunnyUpper,
new THREE.BoxGeometry(0.02, 0.54, 0.025),
paintDark,
[0.51, 1.16, -0.07]
);
addSphere(
bunnyUpper,
furMaterial,
[-0.53, 0.98, 0],
[0.22, 0.22, 0.22]
);
const head = new THREE.Group();
head.position.set(0, 2.02, 0);
bunnyUpper.add(head);
addSphere(head, furMaterial, [0, 0, 0], [0.59, 0.55, 0.52]);
addSphere(head, furMaterial, [0.45, -0.09, 0], [0.25, 0.20, 0.31]);
addSphere(head, bunnyPink, [0.67, -0.06, 0], [0.10, 0.075, 0.095]);
// 未来飞行目镜。
const goggleBand = addMesh(
head,
new THREE.TorusGeometry(0.515, 0.028, 8, 48),
visorMaterial,
[-0.015, 0.12, 0]
);
goggleBand.rotation.y = Math.PI / 2;
goggleBand.scale.set(1, 0.88, 1);
[-1, 1].forEach(side => {
addSphere(
head,
visorMaterial,
[0.45, 0.14, side * 0.19],
[0.13, 0.16, 0.18]
);
addSphere(
head,
eyeMaterial,
[0.555, 0.15, side * 0.19],
[0.035, 0.085, 0.068],
false
);
addSphere(
head,
new THREE.MeshBasicMaterial({ color: 0xffffff, toneMapped: false }),
[0.582, 0.19, side * 0.19 - 0.018],
[0.012, 0.025, 0.018],
false
);
addSphere(
head,
bunnyPink,
[0.48, -0.11, side * 0.31],
[0.055, 0.034, 0.07],
false
);
});
const mouthCurve = new THREE.CatmullRomCurve3([
new V3(0.64, -0.16, 0),
new V3(0.60, -0.205, 0),
new V3(0.54, -0.17, 0)
]);
addMesh(
head,
new THREE.TubeGeometry(mouthCurve, 12, 0.014, 6, false),
paintDark,
[0, 0, 0],
[1, 1, 1],
false
);
const whiskerMaterial = new THREE.LineBasicMaterial({
color: 0xb7bed4,
transparent: true,
opacity: 0.46
});
[-1, 1].forEach(side => {
for (let i = 0; i < 2; i++) {
const points = [
new V3(0.56, -0.08 + i * 0.045, side * 0.27),
new V3(0.79, -0.09 + i * 0.07, side * 0.39),
new V3(0.95, -0.06 + i * 0.08, side * 0.48)
];
const line = new THREE.Line(
new THREE.BufferGeometry().setFromPoints(points),
whiskerMaterial
);
head.add(line);
}
});
// 额前的小绒毛。
addSphere(head, furMaterial, [-0.02, 0.52, -0.15], [0.14, 0.20, 0.12]);
addSphere(head, furMaterial, [-0.07, 0.50, 0.10], [0.12, 0.19, 0.11]);
// 长耳朵由两段可摆动的软体关节组成。
const ears = [];
function createEar(side) {
const base = new THREE.Group();
base.position.set(-0.04, 0.40, side * 0.27);
head.add(base);
const lower = new THREE.Group();
base.add(lower);
addSphere(lower, furMaterial, [0, 0.43, 0], [0.18, 0.54, 0.21]);
addSphere(lower, bunnyPink, [0.12, 0.44, 0], [0.065, 0.39, 0.13], false);
const upper = new THREE.Group();
upper.position.set(0, 0.81, 0);
lower.add(upper);
addSphere(upper, furMaterial, [0, 0.40, 0], [0.15, 0.49, 0.18]);
addSphere(upper, bunnyPink, [0.105, 0.40, 0], [0.055, 0.35, 0.11], false);
addSphere(
upper,
side > 0 ? paintGreen : paintRed,
[0, 0.86, 0],
[0.065, 0.09, 0.075],
false
);
ears.push({
base,
lower,
upper,
side,
phase: side > 0 ? 0.9 : 2.4
});
}
createEar(-1);
createEar(1);
// 小型喷气背包。
addSphere(
bunnyUpper,
paintDark,
[-0.46, 1.33, 0],
[0.27, 0.43, 0.34]
);
addSphere(
bunnyUpper,
paintGreen,
[-0.59, 1.42, 0],
[0.10, 0.25, 0.26]
);
const bunnyFlames = [];
const bunnyThrusterMaterial = new THREE.MeshBasicMaterial({
color: 0x8affdf,
transparent: true,
opacity: 0.28,
blending: THREE.AdditiveBlending,
depthWrite: false,
side: THREE.DoubleSide,
toneMapped: false
});
[-0.18, 0.18].forEach(z => {
const nozzle = addMesh(
bunnyUpper,
new THREE.CylinderGeometry(0.09, 0.12, 0.28, 14),
paintDark,
[-0.68, 1.22, z]
);
nozzle.rotation.z = Math.PI / 2;
const glow = addMesh(
bunnyUpper,
new THREE.CircleGeometry(0.08, 16),
cyanGlow,
[-0.825, 1.22, z],
[1, 1, 1],
false
);
glow.rotation.y = -Math.PI / 2;
const flame = addMesh(
bunnyUpper,
new THREE.ConeGeometry(0.07, 0.48, 12, 1, true),
bunnyThrusterMaterial,
[-0.98, 1.22, z],
[1, 0.5, 1],
false
);
flame.rotation.z = Math.PI / 2;
flame.userData.noShadow = true;
bunnyFlames.push(flame);
});
const bunnyThrusterLight = new THREE.PointLight(0x7fffe4, 1.4, 3.2, 2);
bunnyThrusterLight.position.set(-0.88, 1.22, 0);
bunnyUpper.add(bunnyThrusterLight);
// 红色围巾。
const scarfNeck = addMesh(
bunnyUpper,
new THREE.TorusGeometry(0.35, 0.065, 8, 28),
scarfMaterial,
[0, 1.55, 0]
);
scarfNeck.rotation.x = Math.PI / 2;
const scarfSegments = [];
const scarfOne = new THREE.Group();
scarfOne.position.set(-0.23, 1.54, 0.04);
bunnyUpper.add(scarfOne);
addMesh(
scarfOne,
new THREE.BoxGeometry(0.55, 0.10, 0.035),
scarfMaterial,
[-0.25, -0.015, 0]
);
const scarfTwo = new THREE.Group();
scarfTwo.position.set(-0.52, 0, 0);
scarfOne.add(scarfTwo);
addMesh(
scarfTwo,
new THREE.BoxGeometry(0.46, 0.075, 0.03),
scarfMaterial,
[-0.21, -0.04, 0]
);
scarfSegments.push(scarfOne, scarfTwo);
// 手臂。
const bunnyArms = [];
[-1, 1].forEach(side => {
const arm = new THREE.Group();
arm.position.set(0, 1.43, side * 0.45);
bunnyUpper.add(arm);
addSphere(arm, suitMaterial, [0, 0, 0], [0.17, 0.19, 0.17]);
addMesh(
arm,
new THREE.CapsuleGeometry(0.125, 0.31, 5, 10),
suitMaterial,
[0, -0.25, 0]
);
const cuff = addMesh(
arm,
new THREE.TorusGeometry(0.13, 0.032, 7, 18),
paintGreen,
[0, -0.43, 0]
);
cuff.rotation.x = Math.PI / 2;
addSphere(arm, furMaterial, [0.02, -0.56, 0], [0.15, 0.15, 0.14]);
bunnyArms.push({ arm, side });
});
// 腿部与未来感软靴。
const bunnyLegs = [];
[-1, 1].forEach(side => {
const leg = new THREE.Group();
leg.position.set(0, 0.72, side * 0.23);
bunnyModel.add(leg);
addMesh(
leg,
new THREE.CapsuleGeometry(0.14, 0.31, 5, 10),
suitMaterial,
[0, -0.20, 0]
);
addSphere(
leg,
paintGreen,
[0.06, -0.51, 0],
[0.30, 0.17, 0.20]
);
addMesh(
leg,
new THREE.BoxGeometry(0.37, 0.045, 0.24),
paintDark,
[0.09, -0.655, 0]
);
const lightStrip = addMesh(
leg,
new THREE.BoxGeometry(0.18, 0.018, 0.08),
cyanGlow,
[0.12, -0.51, 0.18],
[1, 1, 1],
false
);
lightStrip.rotation.x = -0.1;
bunnyLegs.push({ leg, side });
});
bunnyModel.traverse(object => {
if (object.isMesh && !object.userData.noShadow) {
object.castShadow = true;
object.receiveShadow = true;
}
});
// ------------------------------------------------------------
// 海边休息平台
// ------------------------------------------------------------
const platformCenter = new V3(-4.24, 0, 4.58);
const P0 = new V3(-4.83, 0.635, 4.68);
const P1 = new V3(-3.62, 0.635, 4.50);
const platform = new THREE.Group();
platform.position.set(platformCenter.x, 0, platformCenter.z);
scene.add(platform);
addMesh(
platform,
new THREE.CylinderGeometry(1.62, 1.48, 0.22, 48),
paintWhite,
[0, 0.50, 0]
);
addMesh(
platform,
new THREE.CylinderGeometry(1.12, 1.50, 0.32, 40),
paintDark,
[0, 0.24, 0]
);
const platformRing = addMesh(
platform,
new THREE.TorusGeometry(1.42, 0.055, 8, 64),
cyanGlow,
[0, 0.62, 0],
[1, 1, 1],
false
);
platformRing.rotation.x = Math.PI / 2;
const innerRing = addMesh(
platform,
new THREE.TorusGeometry(0.77, 0.018, 6, 56),
new THREE.MeshBasicMaterial({
color: 0x9f7cff,
transparent: true,
opacity: 0.52,
blending: THREE.AdditiveBlending,
depthWrite: false
}),
[0, 0.628, 0],
[1, 1, 1],
false
);
innerRing.rotation.x = Math.PI / 2;
for (let i = 0; i < 6; i++) {
const angle = (i / 6) * TAU;
addMesh(
platform,
new THREE.BoxGeometry(0.43, 0.018, 0.035),
paintGreen,
[Math.cos(angle) * 1.15, 0.626, Math.sin(angle) * 1.15],
[1, 1, 1],
false
).rotation.y = -angle;
}
const platformLabel = new THREE.Mesh(
new THREE.PlaneGeometry(0.73, 0.18),
new THREE.MeshBasicMaterial({
map: makeTextTexture("REST / 01", ""),
transparent: true,
depthWrite: false
})
);
platformLabel.position.set(0, 0.638, 0.20);
platformLabel.rotation.x = -Math.PI / 2;
platform.add(platformLabel);
[-1, 1].forEach(side => {
const antenna = addMesh(
platform,
new THREE.CylinderGeometry(0.018, 0.028, 0.68, 8),
paintDark,
[side * 1.22, 0.91, -0.35]
);
addSphere(
platform,
side < 0 ? navRed : navGreen,
[side * 1.22, 1.25, -0.35],
[0.045, 0.045, 0.045],
false
);
});
// ------------------------------------------------------------
// 漂浮标语:just breathe
// ------------------------------------------------------------
const titleGroup = new THREE.Group();
titleGroup.position.set(-3.8, 9.0, -27.5);
titleGroup.rotation.y = 0.035;
scene.add(titleGroup);
const titleTexture = makeTextTexture("just breathe", "A SMALL PLACE BEYOND THE SKY");
const titlePlane = new THREE.Mesh(
new THREE.PlaneGeometry(10.4, 2.6),
new THREE.MeshBasicMaterial({
map: titleTexture,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
toneMapped: false,
fog: false
})
);
titleGroup.add(titlePlane);
const titleHalo = new THREE.Mesh(
new THREE.PlaneGeometry(11.5, 3.15),
new THREE.MeshBasicMaterial({
map: titleTexture,
color: 0x6fffe0,
transparent: true,
opacity: 0.09,
depthWrite: false,
blending: THREE.AdditiveBlending,
toneMapped: false,
fog: false
})
);
titleHalo.position.z = -0.02;
titleGroup.add(titleHalo);
const titleOrbit = addMesh(
titleGroup,
new THREE.TorusGeometry(4.65, 0.015, 6, 96),
new THREE.MeshBasicMaterial({
color: 0x93d9ff,
transparent: true,
opacity: 0.25,
depthWrite: false,
blending: THREE.AdditiveBlending,
toneMapped: false,
fog: false
}),
[0, 0, -0.08],
[1, 1, 1],
false
);
for (let i = 0; i < 3; i++) {
const angle = i * TAU / 3 + 0.4;
addSphere(
titleGroup,
cyanGlow,
[Math.cos(angle) * 4.65, Math.sin(angle) * 4.65, -0.08],
[0.035, 0.035, 0.035],
false
);
}
// ------------------------------------------------------------
// 平台与机翼之间的“梦境航线”
// ------------------------------------------------------------
fighter.updateMatrixWorld(true);
function wingPoint(x = 0, z = 1.5) {
return new V3(x, 0.145, z).applyMatrix4(fighter.matrixWorld);
}
const initialW0 = wingPoint(-0.55, 1.50);
const initialW1 = wingPoint(0.45, 1.50);
const staticRouteOut = new THREE.CubicBezierCurve3(
P1.clone(),
P1.clone().add(new V3(0.45, 2.8, -0.15)),
initialW0.clone().add(new V3(-0.70, 1.35, 0.80)),
initialW0.clone()
);
const staticRouteBack = new THREE.CubicBezierCurve3(
initialW1.clone(),
initialW1.clone().add(new V3(-0.8, 1.4, -0.4)),
P1.clone().add(new V3(0.9, 2.4, 0.9)),
P1.clone()
);
const routeLineMaterial = new THREE.LineBasicMaterial({
color: 0x79ffe2,
transparent: true,
opacity: 0.15,
depthWrite: false,
blending: THREE.AdditiveBlending
});
function makeRouteLine(curve, opacity) {
const points = curve.getPoints(64);
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = routeLineMaterial.clone();
material.opacity = opacity;
const line = new THREE.Line(geometry, material);
scene.add(line);
return line;
}
makeRouteLine(staticRouteOut, 0.16);
makeRouteLine(staticRouteBack, 0.09);
const routeParticleCount = 34;
const routeParticlePositions = new Float32Array(routeParticleCount * 3);
const routeParticleGeometry = new THREE.BufferGeometry();
routeParticleGeometry.setAttribute(
"position",
new THREE.BufferAttribute(routeParticlePositions, 3).setUsage(THREE.DynamicDrawUsage)
);
const routeParticles = new THREE.Points(
routeParticleGeometry,
new THREE.PointsMaterial({
color: 0xa4ffe8,
size: 0.075,
transparent: true,
opacity: 0.82,
blending: THREE.AdditiveBlending,
depthWrite: false,
sizeAttenuation: true
})
);
scene.add(routeParticles);
// ------------------------------------------------------------
// 近景柔光散景与空气尘埃
// ------------------------------------------------------------
const bokehSprites = [];
for (let i = 0; i < 14; i++) {
const sprite = new THREE.Sprite(
new THREE.SpriteMaterial({
map: glowTexture,
color: i % 2 === 0 ? 0x9c8cff : 0x85ffe2,
transparent: true,
opacity: 0.035 + random() * 0.045,
depthWrite: false,
blending: THREE.AdditiveBlending
})
);
sprite.position.set(
(random() - 0.5) * 22,
1 + random() * 11,
5 + random() * 10
);
const size = 0.2 + random() * 0.65;
sprite.scale.set(size, size, 1);
sprite.userData.base = sprite.position.clone();
sprite.userData.phase = random() * TAU;
sprite.layers.set(1);
scene.add(sprite);
bokehSprites.push(sprite);
}
const dustCount = 130;
const dustPositions = new Float32Array(dustCount * 3);
for (let i = 0; i < dustCount; i++) {
dustPositions[i * 3] = (random() - 0.5) * 28;
dustPositions[i * 3 + 1] = 0.6 + random() * 13;
dustPositions[i * 3 + 2] = -20 + random() * 36;
}
const dustGeometry = new THREE.BufferGeometry();
dustGeometry.setAttribute("position", new THREE.BufferAttribute(dustPositions, 3));
const dust = new THREE.Points(
dustGeometry,
new THREE.PointsMaterial({
color: 0xc8bcff,
size: 0.035,
transparent: true,
opacity: 0.36,
blending: THREE.AdditiveBlending,
depthWrite: false
})
);
dust.layers.set(1);
scene.add(dust);
// ------------------------------------------------------------
// 角色接触阴影与落地光圈
// ------------------------------------------------------------
const shadowCanvas = document.createElement("canvas");
shadowCanvas.width = shadowCanvas.height = 128;
const shadowContext = shadowCanvas.getContext("2d");
const shadowGradient = shadowContext.createRadialGradient(64, 64, 0, 64, 64, 64);
shadowGradient.addColorStop(0, "rgba(255,255,255,.9)");
shadowGradient.addColorStop(0.40, "rgba(255,255,255,.45)");
shadowGradient.addColorStop(1, "rgba(255,255,255,0)");
shadowContext.fillStyle = shadowGradient;
shadowContext.fillRect(0, 0, 128, 128);
const bunnyShadowTexture = new THREE.CanvasTexture(shadowCanvas);
const bunnyShadow = new THREE.Mesh(
new THREE.PlaneGeometry(1.35, 0.95),
new THREE.MeshBasicMaterial({
map: bunnyShadowTexture,
color: 0x080919,
transparent: true,
opacity: 0.30,
depthWrite: false
})
);
bunnyShadow.rotation.x = -Math.PI / 2;
bunnyShadow.renderOrder = 2;
scene.add(bunnyShadow);
const landingRings = [];
for (let i = 0; i < 5; i++) {
const ring = new THREE.Mesh(
new THREE.RingGeometry(0.42, 0.47, 48),
new THREE.MeshBasicMaterial({
color: 0x9effe6,
transparent: true,
opacity: 0,
side: THREE.DoubleSide,
depthWrite: false,
blending: THREE.AdditiveBlending,
toneMapped: false
})
);
ring.rotation.x = -Math.PI / 2;
ring.userData.age = 10;
scene.add(ring);
landingRings.push(ring);
}
function spawnLanding(position) {
const ring = landingRings.find(item => item.userData.age > 0.8) || landingRings[0];
ring.position.copy(position);
ring.position.y += 0.025;
ring.scale.setScalar(0.65);
ring.material.opacity = 0.75;
ring.userData.age = 0;
}
// ------------------------------------------------------------
// 真实平面反射海面
// ------------------------------------------------------------
const isMobile = innerWidth < 700;
const reflectionTarget = new THREE.WebGLRenderTarget(
isMobile ? 512 : 768,
isMobile ? 384 : 576,
{
minFilter: THREE.LinearFilter,
magFilter: THREE.LinearFilter,
format: THREE.RGBAFormat,
depthBuffer: true
}
);
reflectionTarget.texture.generateMipmaps = false;
const mirrorCamera = new THREE.PerspectiveCamera();
mirrorCamera.layers.set(0);
scene.add(mirrorCamera);
const reflectionTextureMatrix = new THREE.Matrix4();
const waterMaterial = new THREE.ShaderMaterial({
uniforms: {
uTime: { value: 0 },
uReflection: { value: reflectionTarget.texture },
uTextureMatrix: { value: reflectionTextureMatrix },
uPlatform: { value: new V2(platformCenter.x, platformCenter.z) }
},
vertexShader: `
precision highp float;
uniform float uTime;
uniform mat4 uTextureMatrix;
varying vec4 vReflectionUv;
varying vec3 vWorldPosition;
void main() {
vec3 p = position;
float waveA =
sin(p.x * 0.29 + p.y * 0.13 + uTime * 0.68) * 0.065;
float waveB =
sin(p.x * 0.57 - p.y * 0.31 - uTime * 0.91) * 0.036;
float waveC =
sin(p.x * 1.11 + p.y * 0.72 + uTime * 1.24) * 0.016;
p.z += waveA + waveB + waveC;
vec4 worldPosition = modelMatrix * vec4(p, 1.0);
vWorldPosition = worldPosition.xyz;
vReflectionUv = uTextureMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * viewMatrix * worldPosition;
}
`,
fragmentShader: `
precision highp float;
uniform sampler2D uReflection;
uniform float uTime;
uniform vec2 uPlatform;
varying vec4 vReflectionUv;
varying vec3 vWorldPosition;
void main() {
vec2 uv = vReflectionUv.xy / max(vReflectionUv.w, 0.0001);
float rippleA =
sin(vWorldPosition.x * 0.62 + vWorldPosition.z * 0.31 + uTime * 0.78);
float rippleB =
sin(vWorldPosition.x * 1.24 - vWorldPosition.z * 0.73 - uTime * 0.96);
uv += vec2(rippleA, rippleB) * 0.0038;
vec3 reflection = texture2D(
uReflection,
clamp(uv, vec2(0.002), vec2(0.998))
).rgb;
vec3 viewDirection = normalize(cameraPosition - vWorldPosition);
float fresnel = pow(
1.0 - clamp(viewDirection.y, 0.0, 1.0),
2.15
);
vec3 deepWater = vec3(0.008, 0.024, 0.082);
vec3 color = deepWater * 0.58;
color += reflection * (0.27 + fresnel * 0.78);
float glint = pow(
max(0.0, rippleA * rippleB),
4.0
);
color += vec3(0.25, 0.82, 0.77) * glint * 0.20;
float platformDistance = length(
vWorldPosition.xz - uPlatform
);
float platformRipple =
sin(platformDistance * 9.0 - uTime * 2.3) *
exp(-platformDistance * 1.65);
color += vec3(0.10, 0.65, 0.61) *
max(0.0, platformRipple) * 0.20;
float distanceFog = 1.0 - exp(-length(vWorldPosition.xz) * 0.009);
color = mix(
color,
vec3(0.045, 0.048, 0.145),
distanceFog * 0.68
);
gl_FragColor = vec4(color, 1.0);
#include <tonemapping_fragment>
#include <colorspace_fragment>
}
`
});
const water = new THREE.Mesh(
new THREE.PlaneGeometry(300, 300, 128, 128),
waterMaterial
);
water.rotation.x = -Math.PI / 2;
water.position.y = 0;
water.receiveShadow = false;
scene.add(water);
// ------------------------------------------------------------
// 动画工具
// ------------------------------------------------------------
function cubicBezier(out, a, b, c, d, t) {
const u = 1 - t;
const u2 = u * u;
const t2 = t * t;
out.set(
u2 * u * a.x + 3 * u2 * t * b.x + 3 * u * t2 * c.x + t2 * t * d.x,
u2 * u * a.y + 3 * u2 * t * b.y + 3 * u * t2 * c.y + t2 * t * d.y,
u2 * u * a.z + 3 * u2 * t * b.z + 3 * u * t2 * c.z + t2 * t * d.z
);
return out;
}
function hopSample(f) {
const u = (f - 0.13) / 0.74;
if (u <= 0 || u >= 1) {
return { h: 0, u: 0 };
}
return {
h: Math.sin(Math.PI * u) * 0.78,
u
};
}
const pose = {
position: new V3(),
ground: new V3(),
stage: "wing",
surface: "wing",
height: 0,
hopU: 0,
air: false,
thrust: false,
yaw: 0,
pitch: 0,
bank: 0
};
function getBunnyPose(time) {
// 从 20 秒循环的第 10 秒开始,让首屏先看到机翼上的 Bunny。
const t = (time + 10) % 20;
const hopDuration = 5 / 3;
if (t < 5) {
const progress = t / 5;
const f = (t / hopDuration) % 1;
const hop = hopSample(f);
pose.position.set(
THREE.MathUtils.lerp(P0.x, P1.x, progress),
P0.y + hop.h,
THREE.MathUtils.lerp(P0.z, P1.z, progress) +
Math.sin(progress * Math.PI) * 0.10
);
pose.ground.set(pose.position.x, P0.y, pose.position.z);
pose.stage = "platform";
pose.surface = "platform";
pose.height = hop.h;
pose.hopU = hop.u;
pose.air = hop.h > 0.015;
pose.thrust = false;
pose.yaw = -0.32;
pose.pitch = -0.02;
pose.bank = Math.sin(progress * Math.PI * 2) * 0.035;
} else if (t < 9) {
const q = (t - 5) / 4;
const wing = wingPoint(-0.55, 1.50);
const c1 = P1.clone().add(new V3(0.45, 2.8, -0.15));
const c2 = wing.clone().add(new V3(-0.70, 1.35, 0.80));
cubicBezier(pose.position, P1, c1, c2, wing, smooth(q));
pose.ground.set(0, 0, 0);
pose.stage = "transfer";
pose.surface = null;
pose.height = 0;
pose.hopU = q;
pose.air = true;
pose.thrust = true;
pose.yaw = THREE.MathUtils.lerp(-0.32, fighter.rotation.y + 0.02, smooth(q));
pose.pitch = Math.sin(q * TAU) * 0.14;
pose.bank = Math.sin(q * Math.PI) * 0.12;
} else if (t < 14) {
const progress = (t - 9) / 5;
const f = ((t - 9) / hopDuration) % 1;
const hop = hopSample(f);
const localX = THREE.MathUtils.lerp(-0.55, 0.45, progress);
const localZ = 1.50 + Math.sin(progress * Math.PI) * 0.12;
pose.ground.copy(wingPoint(localX, localZ));
pose.position.copy(pose.ground);
pose.position.y += hop.h;
pose.stage = "wing";
pose.surface = "wing";
pose.height = hop.h;
pose.hopU = hop.u;
pose.air = hop.h > 0.015;
pose.thrust = false;
pose.yaw = fighter.rotation.y + 0.02;
pose.pitch = fighter.rotation.x;
pose.bank = fighter.rotation.z * 0.55;
} else if (t < 18) {
const q = (t - 14) / 4;
const wing = wingPoint(0.45, 1.50);
const c1 = wing.clone().add(new V3(-0.8, 1.4, -0.4));
const c2 = P1.clone().add(new V3(0.9, 2.4, 0.9));
cubicBezier(pose.position, wing, c1, c2, P1, smooth(q));
pose.ground.set(0, 0, 0);
pose.stage = "return";
pose.surface = null;
pose.height = 0;
pose.hopU = q;
pose.air = true;
pose.thrust = true;
pose.yaw = THREE.MathUtils.lerp(fighter.rotation.y + 0.02, -0.32, smooth(q));
pose.pitch = -Math.sin(q * TAU) * 0.14;
pose.bank = -Math.sin(q * Math.PI) * 0.12;
} else {
pose.position.copy(P1);
pose.ground.copy(P1);
pose.stage = "platform";
pose.surface = "platform";
pose.height = 0;
pose.hopU = 0;
pose.air = false;
pose.thrust = false;
pose.yaw = -0.32;
pose.pitch = 0;
pose.bank = 0;
}
return pose;
}
let previousBunnyPose = null;
let previousBunnyY = 0;
let currentStretch = 0;
let bunnyThrust = 0;
function updateFighter(time) {
const motion = reducedMotion ? 0.35 : 1;
fighter.position.set(
1.15 + Math.sin(time * 0.29) * 0.18 * motion,
3.24 + Math.sin(time * 0.73) * 0.15 * motion,
-2.65 + Math.cos(time * 0.31) * 0.13 * motion
);
fighter.rotation.set(
-0.018 + Math.sin(time * 0.43) * 0.018 * motion,
-0.08 + Math.sin(time * 0.27) * 0.034 * motion,
0.014 + Math.sin(time * 0.61) * 0.027 * motion
);
const pulse = 0.5 + 0.5 * Math.sin(time * 5.1);
const pulse2 = 0.5 + 0.5 * Math.sin(time * 4.2 + 1.2);
fighterFlames.forEach((flame, index) => {
const p = index === 0 ? pulse : pulse2;
flame.scale.y = 0.68 + p * 0.75;
flame.material.opacity = 0.30 + p * 0.20;
});
fighterThrusterLights.forEach((light, index) => {
light.intensity = 5.2 + (index === 0 ? pulse : pulse2) * 3.2;
});
const positionAttribute = exhaustGeometry.getAttribute("position");
for (let i = 0; i < exhaustCount; i++) {
const data = exhaustData[i];
const life = (time * 0.22 + data.seed) % 1;
const spread = life * life * 0.24;
positionAttribute.setXYZ(
i,
-3.65 - life * 7.6,
Math.sin(life * 8 + data.seed * TAU) * spread * 0.35,
data.lane + data.drift * spread
);
}
positionAttribute.needsUpdate = true;
}
function updateBunny(time, dt) {
updateFighter(time);
fighter.updateMatrixWorld(true);
const currentPose = getBunnyPose(time);
bunnyRoot.position.copy(currentPose.position);
bunnyRoot.rotation.set(
currentPose.pitch,
currentPose.yaw,
currentPose.bank,
"YXZ"
);
if (!previousBunnyPose) {
previousBunnyY = bunnyRoot.position.y;
}
const verticalVelocity = dt > 0
? (bunnyRoot.position.y - previousBunnyY) / dt
: 0;
const stretchTarget = THREE.MathUtils.clamp(
verticalVelocity * 0.035,
-0.085,
0.15
) + (currentPose.air ? 0.035 : 0);
currentStretch +=
(stretchTarget - currentStretch) *
Math.min(1, dt * 12);
const breathing = Math.sin(time * 2.1) * 0.006;
const sx = 1 - currentStretch * 0.43;
const sy = 1 + currentStretch + breathing;
const sz = 1 - currentStretch * 0.36;
bunnyModel.scale.set(sx, sy, sz);
bunnyUpper.rotation.z = -0.035 - currentPose.air * 0.055;
bunnyUpper.rotation.x = currentPose.pitch * 0.22;
head.rotation.z = 0.04 + currentPose.air * 0.035;
head.rotation.x = Math.sin(time * 1.7) * 0.025;
const hopSwing = Math.sin(currentPose.hopU * Math.PI);
const transferSwing =
currentPose.stage === "transfer" || currentPose.stage === "return"
? Math.sin(currentPose.hopU * Math.PI)
: 0;
const swing = Math.max(hopSwing, transferSwing);
bunnyLegs.forEach(({ leg, side }, index) => {
const offset = index === 0 ? 0.04 : -0.08;
leg.rotation.z = offset + swing * (index === 0 ? 0.76 : 0.56);
leg.rotation.x = side * 0.03;
});
bunnyArms.forEach(({ arm, side }, index) => {
const offset = index === 0 ? -0.16 : -0.11;
arm.rotation.z = offset - swing * (index === 0 ? 0.62 : 0.46);
arm.rotation.x = side * 0.08 + swing * side * 0.08;
});
ears.forEach((ear, index) => {
const waveA = Math.sin(time * 2.35 + ear.phase);
const waveB = Math.sin(time * 2.35 + ear.phase - 0.48);
ear.base.rotation.x =
Math.sin(time * 1.3 + ear.phase) * 0.028;
ear.lower.rotation.z =
0.065 + waveA * 0.035 + currentPose.air * 0.09 +
(index === 0 ? 0.025 : 0);
ear.upper.rotation.z =
0.095 + waveB * 0.065 +
currentPose.air * 0.16 +
Math.abs(verticalVelocity) * 0.004;
});
scarfSegments.forEach((segment, index) => {
segment.rotation.z =
0.06 + Math.sin(time * 2.2 - index * 0.7) * 0.045 +
currentPose.air * (index + 1) * 0.055;
segment.rotation.y =
Math.sin(time * 1.6 - index * 0.8) * 0.06;
});
bunnyThrust +=
((currentPose.thrust ? 1 : 0) - bunnyThrust) *
Math.min(1, dt * 7);
bunnyFlames.forEach((flame, index) => {
const flicker =
0.84 + Math.sin(time * 28 + index * 2.1) * 0.16;
flame.scale.y = 0.36 + bunnyThrust * flicker * 1.25;
flame.material.opacity = 0.15 + bunnyThrust * 0.55;
});
bunnyThrusterMaterial.opacity = 0.20 + bunnyThrust * 0.55;
bunnyThrusterLight.intensity = 0.8 + bunnyThrust * 7;
eyeMaterial.color.setRGB(
0.54 + Math.sin(time * 1.7) * 0.04,
1,
0.86 + Math.sin(time * 2.1) * 0.05
);
if (currentPose.surface) {
bunnyShadow.position.set(
currentPose.ground.x,
currentPose.ground.y + 0.018,
currentPose.ground.z
);
const shadowScale = 1 + currentPose.height * 0.16;
bunnyShadow.scale.set(shadowScale, shadowScale, 1);
bunnyShadow.material.opacity =
0.31 / (1 + currentPose.height * 1.25);
} else {
bunnyShadow.material.opacity *= 0.84;
}
if (
previousBunnyPose &&
currentPose.surface &&
!currentPose.air &&
previousBunnyPose.air
) {
spawnLanding(currentPose.ground);
}
previousBunnyPose = {
air: currentPose.air,
surface: currentPose.surface
};
previousBunnyY = bunnyRoot.position.y;
}
function updateLandingRings(dt) {
landingRings.forEach(ring => {
ring.userData.age += dt;
if (ring.userData.age < 1) {
const p = ring.userData.age;
ring.scale.setScalar(0.65 + p * 2.4);
ring.material.opacity = (1 - p) * 0.62;
} else {
ring.material.opacity = 0;
}
});
}
function updateEnvironment(time, dt) {
const motion = reducedMotion ? 0.35 : 1;
clouds.forEach(cloud => {
const data = cloud.userData;
cloud.position.x = data.base.x + Math.sin(time * data.speed + data.phase) * 1.25 * motion;
cloud.position.y = data.base.y + Math.sin(time * 0.11 + data.phase) * 0.16 * motion;
cloud.rotation.y = Math.sin(time * 0.04 + data.phase) * 0.06 * motion;
});
coastRockGroups.forEach(rock => {
rock.position.y =
rock.userData.base.y +
Math.sin(time * 0.42 + rock.userData.phase) * 0.09 * motion;
rock.rotation.y = Math.sin(time * 0.08 + rock.userData.phase) * 0.04;
});
titleGroup.position.y =
9.0 + Math.sin(time * 0.34) * 0.17 * motion;
titleGroup.rotation.z = Math.sin(time * 0.15) * 0.012 * motion;
titlePlane.material.opacity = 0.80 + Math.sin(time * 0.7) * 0.08;
titleHalo.material.opacity = 0.075 + Math.sin(time * 0.7) * 0.018;
titleOrbit.rotation.z = time * 0.012 * motion;
bokehSprites.forEach(sprite => {
const base = sprite.userData.base;
const phase = sprite.userData.phase;
sprite.position.x = base.x + Math.sin(time * 0.13 + phase) * 0.45;
sprite.position.y = base.y + Math.cos(time * 0.11 + phase) * 0.30;
});
dust.rotation.y = Math.sin(time * 0.025) * 0.035;
dust.position.y = Math.sin(time * 0.16) * 0.12;
for (let i = 0; i < routeParticleCount; i++) {
const progress = (time * 0.065 + i / routeParticleCount) % 1;
const point = staticRouteOut.getPoint(progress);
point.y += Math.sin(progress * Math.PI) * Math.sin(time * 1.7 + i) * 0.09;
routeParticlePositions[i * 3] = point.x;
routeParticlePositions[i * 3 + 1] = point.y;
routeParticlePositions[i * 3 + 2] = point.z;
}
routeParticleGeometry.getAttribute("position").needsUpdate = true;
}
const pointer = {
x: 0,
y: 0,
targetX: 0,
targetY: 0
};
addEventListener("pointermove", event => {
pointer.targetX = (event.clientX / innerWidth - 0.5) * 2;
pointer.targetY = (event.clientY / innerHeight - 0.5) * 2;
}, { passive: true });
addEventListener("pointerleave", () => {
pointer.targetX = 0;
pointer.targetY = 0;
});
function updateCamera(time, dt) {
const motion = reducedMotion ? 0.35 : 1;
const mobileFactor = camera.aspect < 0.8 ? 1.28 : 1;
pointer.x += (pointer.targetX - pointer.x) * Math.min(1, dt * 1.8);
pointer.y += (pointer.targetY - pointer.y) * Math.min(1, dt * 1.8);
const azimuth =
0.32 +
Math.sin(time * 0.055) * 0.105 * motion +
Math.sin(time * 0.021) * 0.035 * motion;
const push =
1.25 * (
0.5 +
0.5 * Math.sin(time * 0.09 - 1.0)
);
const radius = (18.1 - push) * mobileFactor;
const height =
(4.50 + Math.sin(time * 0.047) * 0.62 * motion) *
(camera.aspect < 0.8 ? 1.12 : 1);
desiredCamera.set(
Math.sin(azimuth) * radius + pointer.x * 0.42 * motion,
height - pointer.y * 0.28 * motion,
Math.cos(azimuth) * radius
);
desiredLook.set(
-0.38 + bunnyRoot.position.x * 0.075,
2.45 + (bunnyRoot.position.y - 3.0) * 0.055,
-0.85 + bunnyRoot.position.z * 0.035
);
const cameraEase = 1 - Math.exp(-dt * 0.9);
camera.position.lerp(desiredCamera, cameraEase);
cameraLook.lerp(desiredLook, 1 - Math.exp(-dt * 1.1));
camera.lookAt(cameraLook);
camera.rotateZ(
Math.sin(time * 0.041) * 0.0045 * motion +
pointer.x * 0.002 * motion
);
}
function updateReflection() {
water.updateMatrixWorld(true);
mirrorCamera.position.set(
camera.position.x,
-camera.position.y,
camera.position.z
);
mirrorCamera.up.set(0, -1, 0);
mirrorCamera.lookAt(
cameraLook.x,
-cameraLook.y,
cameraLook.z
);
mirrorCamera.near = camera.near;
mirrorCamera.far = camera.far;
mirrorCamera.fov = camera.fov;
mirrorCamera.aspect = camera.aspect;
mirrorCamera.updateProjectionMatrix();
mirrorCamera.updateMatrixWorld(true);
reflectionTextureMatrix.set(
0.5, 0.0, 0.0, 0.5,
0.0, 0.5, 0.0, 0.5,
0.0, 0.0, 0.5, 0.5,
0.0, 0.0, 0.0, 1.0
);
reflectionTextureMatrix.multiply(mirrorCamera.projectionMatrix);
reflectionTextureMatrix.multiply(mirrorCamera.matrixWorldInverse);
reflectionTextureMatrix.multiply(water.matrixWorld);
water.visible = false;
renderer.setRenderTarget(reflectionTarget);
renderer.clear();
renderer.render(scene, mirrorCamera);
renderer.setRenderTarget(null);
water.visible = true;
}
// ------------------------------------------------------------
// 尺寸、暂停与渲染循环
// ------------------------------------------------------------
let paused = false;
let simTime = 0;
let lastTime = performance.now();
let frameCount = 0;
let timecodeAccumulator = 0;
function resize() {
const width = innerWidth;
const height = innerHeight;
renderer.setPixelRatio(
Math.min(devicePixelRatio, width < 700 ? 1.4 : 1.75)
);
renderer.setSize(width, height);
camera.aspect = width / height;
camera.fov = width < 700 ? 51 : 43;
camera.updateProjectionMatrix();
reflectionTarget.setSize(
width < 700 ? 512 : 768,
width < 700 ? 384 : 576
);
}
addEventListener("resize", resize);
resize();
function setPaused(value) {
paused = value;
pauseButton.textContent = paused ? "Play ▶" : "Pause Ⅱ";
pauseButton.setAttribute(
"aria-label",
paused ? "继续动画" : "暂停动画"
);
}
pauseButton.addEventListener("click", () => {
setPaused(!paused);
});
addEventListener("keydown", event => {
if (event.code === "Space" || event.code === "KeyP") {
if (event.code === "Space") event.preventDefault();
setPaused(!paused);
}
});
document.addEventListener("visibilitychange", () => {
lastTime = performance.now();
});
function animate(now) {
requestAnimationFrame(animate);
const rawDt = Math.min((now - lastTime) / 1000, 0.04);
lastTime = now;
const dt = paused || document.hidden ? 0 : rawDt;
simTime += dt;
updateBunny(simTime, dt || 0.0001);
updateEnvironment(simTime, dt || 0.0001);
updateLandingRings(dt);
updateCamera(simTime, Math.max(dt, 0.001));
sky.material.uniforms.uTime.value = simTime;
stars.material.uniforms.uTime.value = simTime;
moon.material.uniforms.uTime.value = simTime;
waterMaterial.uniforms.uTime.value = simTime;
// 移动端隔帧更新倒影,降低填充率压力。
if (innerWidth >= 700 || frameCount % 2 === 0) {
updateReflection();
}
renderer.render(scene, camera);
timecodeAccumulator += rawDt;
if (timecodeAccumulator > 0.15) {
timecodeAccumulator = 0;
const seconds = Math.floor(simTime % 120);
const minutes = Math.floor(seconds / 60);
const rest = seconds % 60;
timecode.textContent =
`T+ ${String(minutes).padStart(2, "0")}:` +
`${String(rest).padStart(2, "0")}`;
}
frameCount++;
if (frameCount === 2) {
setTimeout(() => document.body.classList.add("loaded"), 180);
}
}
requestAnimationFrame(animate);
})();
</script>
</body>
</html>
Timing details
- Actual wait (client)
- 1.47s
- Client preparation
- 0.05s
- Proxy preparation
- 0.00s
- Upstream response headers
- 1.23s
- Upstream first byte
- 1.23s
- Upstream first token
- 1.23s
- Upstream retries
- 0
Actual wait uses the client clock. Upstream timings start after proxy preparation and are cumulative; do not add these values. Upstream delay includes network, scheduling, processing and buffering; it does not prove provider queueing.
Diagnostic receipt
{
"attempts": 1,
"startedAt": "2026-09-29T03:43:55.792Z",
"transport": "railway",
"ttftSource": "proxy",
"clientTtftMs": 1469.0999999046326,
"completionId": "gen-1790653435-M4CIJLcvCG7u0G6yIaGN",
"streamResumes": 15,
"proxyPrepareMs": 2,
"proxyRequestId": "N8jtqrSTSo-vM0RG2prcFg",
"upstreamStatus": 200,
"upstreamTtftMs": 1228,
"clientHeadersMs": 240.69999980926514,
"clientPrepareMs": 45.299999713897705,
"upstreamHeadersMs": 1228,
"upstreamFirstByteMs": 1228,
"clientFirstContentMs": 564315.5999999046
}