01
THE_BEACH
PICK A WEATHER // WAIT FOR THE TIDE
SCENE_CONTROLS
VIEW BEACH
WEATHER SUNNY
TIME 19:18
DECK MODEL // "HQ_HOTEL OUTDOOR PLANTS SET" BY PANDORA-LAND // SIMPLIFIED AND RECOLORED FOR THIS SCENE
HOW IT'S MADE [+]
<template>
<div ref="wrapRef" class="beach-wrapper">
<canvas ref="canvasRef" class="beach-canvas"></canvas>
<div v-if="loading" class="beach-loading font-mono" aria-hidden="true">
LOADING_SCENE_<span class="beach-caret">▌</span>
</div>
</div>
</template>
<script setup lang="ts">
import { onBeforeUnmount, onMounted, ref, watch } from "vue"
import * as THREE from "three"
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js"
// LAB_08: a stylized beach. Geometry comes from public/beach/beach.glb
// (authored by scripts/blender/beach_scene.py); sky, water, weather, tide and
// light are all shader-side here. The page owns every control and the SFX —
// this component only takes weather + time and reports lightning strikes.
type Weather = "sunny" | "cloudy" | "rain" | "storm"
const props = withDefaults(defineProps<{
weather?: Weather
timeOfDay?: number // 0..24, fractional hours
view?: "beach" | "deck"
paused?: boolean // sleep timer: freeze the scene, audio keeps running
}>(), {
weather: "sunny",
timeOfDay: 19.3,
view: "beach",
paused: false,
})
// camera poses: standing at the deck's edge vs seated on the HQ_HOTEL bench.
// the deck rides on the beach crest — its lift (DECK_DZ, printed by
// scripts/blender/beach_scene.py) is baked into the deck pose here
const CAM_VIEWS = {
beach: { pos: new THREE.Vector3(0, 2.6, 8.5), look: new THREE.Vector3(0, 0.9, -6) },
deck: { pos: new THREE.Vector3(2.44, 1.83, 11.4), look: new THREE.Vector3(1.4, 0.5, -2) },
}
const emit = defineEmits<{
(e: "lightning", strength: number): void
(e: "mix", m: { waves: number; rain: number; gulls: number; storm: number; night: number }): void
}>()
const wrapRef = ref<HTMLDivElement | null>(null)
const canvasRef = ref<HTMLCanvasElement | null>(null)
const loading = ref(true)
// mirrored from scripts/blender/beach_scene.py — the water shader re-derives
// the sand height under the waves from these for the shoreline foam
const SHORE_Z0 = 0.0
const SHORE_SLOPE = 0.09
/* ── weather presets, crossfaded via one exponential lerp ── */
const PRESETS: Record<Weather, {
overcast: number; clouds: number; cloudTint: THREE.Color
waveAmp: number; waveSpeed: number; chop: number; sway: number
lightDim: number; fogFar: number; rain: number; lightningRate: number
}> = {
sunny: { overcast: 0.05, clouds: 2, cloudTint: new THREE.Color("#fff6ee"), waveAmp: 0.05, waveSpeed: 1.0, chop: 0.1, sway: 0.3, lightDim: 1.0, fogFar: 40, rain: 0, lightningRate: 0 },
cloudy: { overcast: 0.65, clouds: 5, cloudTint: new THREE.Color("#cfd4da"), waveAmp: 0.10, waveSpeed: 1.3, chop: 0.3, sway: 0.7, lightDim: 0.55, fogFar: 34, rain: 0, lightningRate: 0 },
rain: { overcast: 0.85, clouds: 6, cloudTint: new THREE.Color("#9aa3ad"), waveAmp: 0.16, waveSpeed: 1.7, chop: 0.55, sway: 1.6, lightDim: 0.4, fogFar: 28, rain: 0.6, lightningRate: 0 },
storm: { overcast: 1.0, clouds: 6, cloudTint: new THREE.Color("#5c6470"), waveAmp: 0.28, waveSpeed: 2.3, chop: 0.9, sway: 3.0, lightDim: 0.28, fogFar: 22, rain: 1.0, lightningRate: 5 },
}
const cur = {
overcast: 0.05, clouds: 2, cloudTint: new THREE.Color("#fff6ee"),
waveAmp: 0.05, waveSpeed: 1.0, chop: 0.1, sway: 0.3,
lightDim: 1.0, fogFar: 55, rain: 0, lightningRate: 0,
}
/* ── time-of-day palette keyframes (hour → colors), lerped in paletteAt ── */
type Row = { h: number; horizon: THREE.Color; mid: THREE.Color; zenith: THREE.Color; sun: THREE.Color; sunI: number; amb: THREE.Color; stars: number }
const row = (h: number, horizon: string, mid: string, zenith: string, sun: string, sunI: number, amb: string, stars: number): Row => ({
h, horizon: new THREE.Color(horizon), mid: new THREE.Color(mid), zenith: new THREE.Color(zenith), sun: new THREE.Color(sun), sunI, amb: new THREE.Color(amb), stars,
})
const PALETTE: Row[] = [
row(0, "#141b38", "#0b1226", "#030510", "#93a7ff", 0.0, "#242a40", 1),
row(4.5, "#182040", "#0d142c", "#040714", "#93a7ff", 0.0, "#272d44", 1),
row(6, "#ffb08a", "#8090c2", "#2b3a66", "#ffd9b0", 0.45, "#757d9a", 0.15),
row(8, "#d3ecff", "#92c6f2", "#59a2e8", "#fff2d2", 1.0, "#a8bccf", 0),
row(12, "#dcf3ff", "#a2d6f7", "#58acf0", "#fff8e8", 1.12, "#b6c6d6", 0),
row(17.5, "#ffd9a3", "#a9c4e8", "#5f8fd0", "#ffe8b8", 1.0, "#a9b4c4", 0),
row(19.3, "#ff9d5c", "#d97ba0", "#4a5d9e", "#ffb36b", 0.85, "#a58c96", 0.02),
row(20.5, "#e06a63", "#7a5a95", "#232f5c", "#ff8a5c", 0.25, "#5c5a78", 0.4),
row(21.8, "#182040", "#0d142c", "#040714", "#93a7ff", 0.0, "#272d44", 1),
row(24, "#141b38", "#0b1226", "#030510", "#93a7ff", 0.0, "#242a40", 1),
]
const pal = { horizon: new THREE.Color(), mid: new THREE.Color(), zenith: new THREE.Color(), sun: new THREE.Color(), sunI: 0, amb: new THREE.Color(), stars: 0 }
function paletteAt(h: number) {
let a = PALETTE[0], b = PALETTE[PALETTE.length - 1]
for (let i = 0; i < PALETTE.length - 1; i++) {
if (h >= PALETTE[i].h && h <= PALETTE[i + 1].h) { a = PALETTE[i]; b = PALETTE[i + 1]; break }
}
const t = a.h === b.h ? 0 : (h - a.h) / (b.h - a.h)
pal.horizon.lerpColors(a.horizon, b.horizon, t)
pal.mid.lerpColors(a.mid, b.mid, t)
pal.zenith.lerpColors(a.zenith, b.zenith, t)
pal.sun.lerpColors(a.sun, b.sun, t)
pal.amb.lerpColors(a.amb, b.amb, t)
pal.sunI = a.sunI + (b.sunI - a.sunI) * t
pal.stars = a.stars + (b.stars - a.stars) * t
}
/* ── shaders ── */
const SKY_VERT = /* glsl */ `
varying vec3 vDir;
void main() {
vDir = normalize(position);
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}`
const SKY_FRAG = /* glsl */ `
uniform vec3 uHorizon; uniform vec3 uMid; uniform vec3 uZenith;
uniform vec3 uSunDir; uniform vec3 uSunColor;
uniform float uStars; uniform float uOvercast; uniform float uFlash;
varying vec3 vDir;
float hash21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main() {
vec3 dir = normalize(vDir);
float y = clamp(dir.y, -0.1, 1.0);
vec3 col = mix(uHorizon, uMid, smoothstep(0.0, 0.28, y));
col = mix(col, uZenith, smoothstep(0.22, 0.75, y));
float d = dot(dir, normalize(uSunDir));
col += uSunColor * (smoothstep(0.9989, 0.9995, d) + pow(max(d, 0.0), 28.0) * 0.35) * (1.0 - uOvercast * 0.85);
// the moon rises where the sun set: anti-solar, only once it is dark
float m = dot(dir, normalize(-uSunDir));
vec3 moonCol = vec3(0.92, 0.94, 1.0);
col += moonCol * (smoothstep(0.9985, 0.9994, m) * 0.85 + pow(max(m, 0.0), 40.0) * 0.16)
* uStars * (1.0 - uOvercast * 0.9);
vec2 sp = dir.xz / (0.28 + dir.y) * 14.0;
float star = smoothstep(0.93, 0.99, hash21(floor(sp)));
star *= smoothstep(0.28, 0.06, length(fract(sp) - 0.5)); // a dot, not the whole cell
col += vec3(star) * uStars * (1.0 - uOvercast) * smoothstep(0.05, 0.3, y) * 0.9;
float g = dot(col, vec3(0.333));
col = mix(col, vec3(g) * 0.72 + uHorizon * 0.1, uOvercast * 0.8);
col *= mix(1.0, 0.42, uOvercast * uOvercast); // heavy skies get dark, not just gray
col += uFlash;
gl_FragColor = vec4(col, 1.0);
#include <colorspace_fragment>
}`
// one toon material for every GLB mesh: vertex colors, banded sun light,
// height-weighted wind sway (palms) and a wet darkening band (sand)
const TOON_VERT = /* glsl */ `
uniform float uTime; uniform float uSwayAmp; uniform float uPalmHeight;
varying vec3 vN; varying vec3 vWp; varying vec3 vCol;
#include <fog_pars_vertex>
void main() {
vec3 p = position;
float w = pow(smoothstep(0.0, uPalmHeight, p.y), 2.0) * uSwayAmp;
float ph = modelMatrix[3][0] * 1.7; // per-object phase from world x
// storm wind: a steady downwind lean plus layered whip and flutter
float osc = sin(uTime * 1.7 + ph) + 0.5 * sin(uTime * 3.1 + ph) + 0.35 * sin(uTime * 6.3 + ph * 1.7);
p.x += w * (2.2 + osc);
p.z += w * 0.6 * sin(uTime * 2.3 + ph);
#if defined( USE_COLOR_ALPHA )
vCol = color.rgb;
#elif defined( USE_COLOR )
vCol = color;
#else
vCol = vec3(1.0);
#endif
vN = normalize(mat3(modelMatrix) * normal);
vec4 wp = modelMatrix * vec4(p, 1.0);
vWp = wp.xyz;
vec4 mvPosition = viewMatrix * wp;
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}`
const TOON_FRAG = /* glsl */ `
uniform vec3 uSunDir; uniform vec3 uSunColor; uniform vec3 uAmbient;
uniform float uWetLevel; uniform float uWetDarken;
uniform vec3 uLanternA; uniform vec3 uLanternB; uniform float uLanternGlow;
varying vec3 vN; varying vec3 vWp; varying vec3 vCol;
#include <fog_pars_fragment>
void main() {
vec3 n = normalize(vN) * (gl_FrontFacing ? 1.0 : -1.0);
float d = dot(n, normalize(uSunDir));
float band = 0.5 + 0.22 * smoothstep(-0.05, 0.05, d) + 0.28 * smoothstep(0.4, 0.5, d);
vec3 col = vCol * (uAmbient + uSunColor * band);
col *= mix(1.0, uWetDarken, smoothstep(uWetLevel + 0.04, uWetLevel - 0.06, vWp.y));
// cozy pools of candlelight around the two deck lanterns
float dl = distance(vWp, uLanternA);
float dr = distance(vWp, uLanternB);
col += vec3(1.0, 0.55, 0.22) * uLanternGlow * (1.0 / (1.0 + 5.0 * dl * dl) + 1.0 / (1.0 + 5.0 * dr * dr));
gl_FragColor = vec4(col, 1.0);
#include <fog_fragment>
#include <colorspace_fragment>
}`
// fairy lights: warm twinkling bulbs spiralling up the palm trunks. They ride
// the exact same wind math as the palm sway shader so the string never
// detaches from its tree.
const FAIRY_VERT = /* glsl */ `
uniform float uTime; uniform float uSway;
attribute float aSeed; attribute vec3 aBase; attribute float aH; attribute float aAmp;
varying float vTw; varying float vSeed;
void main() {
vec3 p = position;
// identical wind math to the palm shader, per-bulb base/height/amp baked in
float w = pow(smoothstep(0.0, aH, p.y - aBase.y), 2.0) * uSway * aAmp;
float ph = aBase.x * 1.7;
float osc = sin(uTime * 1.7 + ph) + 0.5 * sin(uTime * 3.1 + ph) + 0.35 * sin(uTime * 6.3 + ph * 1.7);
p.x += w * (2.2 + osc);
p.z += w * 0.6 * sin(uTime * 2.3 + ph);
vTw = 0.65 + 0.35 * sin(uTime * (1.2 + fract(aSeed) * 1.6) + aSeed * 21.0);
vSeed = aSeed;
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = 46.0 / max(1.0, -mv.z);
gl_Position = projectionMatrix * mv;
}`
const FAIRY_FRAG = /* glsl */ `
uniform float uNight;
varying float vTw; varying float vSeed;
void main() {
float r = length(gl_PointCoord - 0.5);
float a = smoothstep(0.5, 0.12, r) * vTw * uNight;
vec3 warm = mix(vec3(1.0, 0.72, 0.32), vec3(1.0, 0.55, 0.45), fract(vSeed * 7.31));
gl_FragColor = vec4(warm, a);
#include <colorspace_fragment>
}`
// aura orbs: big, very soft pastel glows drifting slowly over the deck
const AURA_VERT = /* glsl */ `
uniform float uTime;
attribute float aSeed;
varying float vSeed;
void main() {
vec3 p = position;
p.x += sin(uTime * 0.22 + aSeed * 9.0) * 0.55;
p.y += sin(uTime * 0.17 + aSeed * 5.0) * 0.3;
p.z += cos(uTime * 0.19 + aSeed * 7.0) * 0.55;
vSeed = aSeed;
vec4 mv = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = 260.0 / max(1.0, -mv.z);
gl_Position = projectionMatrix * mv;
}`
const AURA_FRAG = /* glsl */ `
uniform float uNight;
varying float vSeed;
void main() {
float r = length(gl_PointCoord - 0.5);
float a = smoothstep(0.5, 0.0, r);
a = a * a * 0.26 * uNight;
float pick = fract(vSeed * 3.7);
vec3 hue = pick < 0.34 ? vec3(1.0, 0.72, 0.35)
: pick < 0.67 ? vec3(0.65, 0.55, 1.0)
: vec3(0.45, 0.9, 0.8);
gl_FragColor = vec4(hue, a);
#include <colorspace_fragment>
}`
// fireflies: soft blinking dots wandering around the beds at night
const FIREFLY_VERT = /* glsl */ `
uniform float uTime;
attribute float aSeed;
varying float vBlink;
void main() {
vec3 p = position;
p.x += sin(uTime * 0.31 + aSeed * 7.0) * 0.8;
p.y += sin(uTime * 0.43 + aSeed * 13.0) * 0.35;
p.z += cos(uTime * 0.27 + aSeed * 5.0) * 0.8;
vBlink = smoothstep(0.35, 0.9, sin(uTime * 0.8 + aSeed * 17.0));
vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
gl_PointSize = 90.0 / max(1.0, -mvPosition.z);
gl_Position = projectionMatrix * mvPosition;
}`
const FIREFLY_FRAG = /* glsl */ `
uniform float uNight;
varying float vBlink;
void main() {
float r = length(gl_PointCoord - 0.5);
float a = smoothstep(0.5, 0.1, r) * vBlink * uNight;
gl_FragColor = vec4(1.0, 0.85, 0.35, a);
#include <colorspace_fragment>
}`
const WATER_VERT = /* glsl */ `
uniform float uTime; uniform float uTide; uniform float uSurge;
uniform float uWaveAmp; uniform float uWaveSpeed; uniform float uChop;
uniform float uShoreZ0; uniform float uShoreSlope;
varying vec3 vN; varying vec3 vWp;
varying float vRelH; varying float vDepth0;
#include <fog_pars_vertex>
void main() {
vec3 p = position;
vec4 wp0 = modelMatrix * vec4(p, 1.0);
// waves keep most of their height into the shallows and collapse right at
// the waterline — the fragment shader paints the break there
float depth0 = -(uShoreSlope * (wp0.z - uShoreZ0));
float att = smoothstep(-0.02, 0.35, depth0);
// 3 directional sines, amplitudes relative to uWaveAmp
vec2 d1 = normalize(vec2(0.2, 1.0)); vec2 d2 = normalize(vec2(-0.6, 1.0)); vec2 d3 = normalize(vec2(0.9, 0.4));
float k1 = 0.9, k2 = 1.7, k3 = 3.1;
float s = uTime * uWaveSpeed;
// negative time term: crests travel along +d, i.e. toward the beach
float p1 = dot(d1, wp0.xz) * k1 - s * 1.1;
float p2 = dot(d2, wp0.xz) * k2 - s * 1.7;
float p3 = dot(d3, wp0.xz) * k3 - s * 2.6;
float a1 = uWaveAmp * att, a2 = uWaveAmp * 0.55 * att, a3 = uWaveAmp * 0.3 * att;
float h = a1 * sin(p1) + a2 * sin(p2) + a3 * sin(p3);
float dhx = a1 * k1 * d1.x * cos(p1) + a2 * k2 * d2.x * cos(p2) + a3 * k3 * d3.x * cos(p3);
float dhz = a1 * k1 * d1.y * cos(p1) + a2 * k2 * d2.y * cos(p2) + a3 * k3 * d3.y * cos(p3);
// waves steepen where the water gets shallow
float chopEff = uChop * (1.0 + 1.4 * (1.0 - smoothstep(0.1, 0.8, depth0)));
p.xz += (d1 * cos(p1) * a1 + d2 * cos(p2) * a2) * chopEff;
// swash: the whole near-shore sheet surges up the sand and drains back,
// which walks the waterline meters up the beach in a storm
float shoreBand = 1.0 - smoothstep(0.1, 0.55, depth0);
p.y += h + uTide + uSurge * shoreBand;
vRelH = h / max(uWaveAmp * 1.85, 0.001);
vDepth0 = depth0;
vN = normalize(vec3(-dhx, 1.0, -dhz));
vec4 wp = modelMatrix * vec4(p, 1.0);
vWp = wp.xyz;
vec4 mvPosition = viewMatrix * wp;
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}`
const WATER_FRAG = /* glsl */ `
uniform vec3 uShallow; uniform vec3 uDeep; uniform vec3 uFoamColor; uniform vec3 uSkyTint;
uniform vec3 uSunDir; uniform vec3 uSunColor; uniform vec3 uAmbient;
uniform float uTime; uniform float uOvercast; uniform float uShoreZ0; uniform float uShoreSlope;
uniform float uSurge;
varying vec3 vN; varying vec3 vWp;
varying float vRelH; varying float vDepth0;
#include <fog_pars_fragment>
float hash21(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); }
void main() {
// analytic sand height below this fragment → depth proxy + foam band
float sandY = uShoreSlope * (vWp.z - uShoreZ0);
float depth = vWp.y - sandY;
float t = smoothstep(0.05, 0.12, depth) * 0.34 + smoothstep(0.22, 0.3, depth) * 0.33 + smoothstep(0.42, 0.52, depth) * 0.33;
vec3 col = mix(uShallow, uDeep, t);
float wob = (hash21(floor(vWp.xz * 6.0 + vec2(uTime * 0.8, 0.0))) - 0.5) * 0.05;
float swash = 0.035 * sin(uTime * 0.7); // the waterline breathes
// the running-up sheet turns white — the crash reads as a broad wash
float foam = smoothstep(0.14 + wob + swash + uSurge * 1.1, 0.03 + wob + swash, depth);
// breakers: crests whiten as they roll into the shallow band
foam += smoothstep(0.3, 0.75, vRelH) * smoothstep(0.8, 0.12, vDepth0);
foam = clamp(foam, 0.0, 1.0);
col = mix(col, uFoamColor, foam * 0.92);
vec3 n = normalize(vN);
vec3 view = normalize(cameraPosition - vWp);
float spec = pow(max(dot(reflect(-normalize(uSunDir), n), view), 0.0), 120.0);
col += uSunColor * smoothstep(0.2, 0.75, spec) * 0.5 * (1.0 - uOvercast);
vec3 light = clamp(uAmbient * 1.15 + uSunColor * 0.5 + vec3(0.12), 0.0, 1.35);
col *= light;
// grazing angles reflect the sky, so the far water melts into the horizon
float fresnel = pow(1.0 - max(dot(view, n), 0.0), 3.0);
col = mix(col, uSkyTint, fresnel * (1.0 - foam) * 0.6);
float g = dot(col, vec3(0.333));
col = mix(col, vec3(g) * 0.9, uOvercast * 0.45);
gl_FragColor = vec4(col, 1.0);
#include <fog_fragment>
#include <colorspace_fragment>
}`
// gulls: one chevron per bird, wingtips flap in the vertex shader
const BIRD_VERT = /* glsl */ `
uniform float uTime;
#include <fog_pars_vertex>
void main() {
vec3 p = position;
float wing = smoothstep(0.08, 0.5, abs(p.x));
p.y += sin(uTime * 9.0 + modelMatrix[3][0] * 3.1) * 0.24 * wing;
vec4 mvPosition = modelViewMatrix * vec4(p, 1.0);
gl_Position = projectionMatrix * mvPosition;
#include <fog_vertex>
}`
const BIRD_FRAG = /* glsl */ `
uniform vec3 uColor; uniform float uOpacity;
#include <fog_pars_fragment>
void main() {
gl_FragColor = vec4(uColor, uOpacity);
#include <fog_fragment>
#include <colorspace_fragment>
}`
const RAIN_VERT = /* glsl */ `
uniform float uTime; uniform float uWind;
attribute float aOffset; attribute float aEnd;
void main() {
vec3 p = position;
float h = 7.0;
p.y = mod(p.y - uTime * 9.0 + aOffset, h);
p.y -= aEnd * 0.35;
// wind-driven slant with slow gusts; streaks align with the fall direction
float slant = uWind * (0.8 + 0.4 * sin(uTime * 0.7));
p.x += (h - p.y) * slant;
gl_Position = projectionMatrix * modelViewMatrix * vec4(p, 1.0);
}`
const RAIN_FRAG = /* glsl */ `
uniform float uRain;
void main() {
gl_FragColor = vec4(0.62, 0.72, 0.85, uRain * 0.45);
#include <colorspace_fragment>
}`
/* ── scene state ── */
let renderer: THREE.WebGLRenderer | null = null
let scene: THREE.Scene
let camera: THREE.PerspectiveCamera
let fog: THREE.Fog
let skyMat: THREE.ShaderMaterial
let waterMat: THREE.ShaderMaterial
let rainMat: THREE.ShaderMaterial
let rainMesh: THREE.LineSegments
let toonMats: THREE.ShaderMaterial[] = []
let sandMat: THREE.ShaderMaterial | null = null
let palmMats: THREE.ShaderMaterial[] = []
let clouds: { mesh: THREE.Mesh; mat: THREE.MeshBasicMaterial; speed: number; baseOpacity: number; baseScale: number; baseY: number }[] = []
let birds: { mesh: THREE.Mesh; cx: number; cy: number; cz: number; r: number; speed: number; theta: number }[] = []
let birdMat: THREE.ShaderMaterial
let boltLine: THREE.Mesh
let boltMat: THREE.MeshBasicMaterial
let boltX = 0
let boltZ = -15
// the fairy-lit palms: trunk curve params mirrored from build_palm in
// beach_scene.py (base, height, lean per t^2), plus warm pools at their feet
const FAIRY_PALMS = [
{ base: [-3.2, 0.18, 2.8] as const, h: 2.6, lean: [0.275, -0.33] as const },
{ base: [4.0, 0.13, 3.6] as const, h: 2.132, lean: [-0.385, -0.22] as const },
]
let fairyLights: THREE.Points | null = null
let fairyLightMat: THREE.ShaderMaterial
let auraMat: THREE.ShaderMaterial
let fireflyMat: THREE.ShaderMaterial
let raf = 0
let running = false
let io: IntersectionObserver | undefined
let reduced = false
let elapsed = 0
let wetLevel = -10
let strikeT = -1
let strikeDur = 0
let nextStrike = 0
const camPos = CAM_VIEWS.beach.pos.clone()
const camLook = CAM_VIEWS.beach.look.clone()
let floraMats: THREE.ShaderMaterial[] = []
let mixT = 0
const sunDir = new THREE.Vector3(0, 1, 0)
const sunColor = new THREE.Color()
const ambient = new THREE.Color()
const cScratch = new THREE.Color()
const fogUniforms = () => ({
fogColor: { value: new THREE.Color() },
fogNear: { value: 14 },
fogFar: { value: 55 },
})
function makeToon(overrides: Partial<{ swayAmp: number; palmHeight: number; wetDarken: number }> = {}) {
const m = new THREE.ShaderMaterial({
vertexShader: TOON_VERT,
fragmentShader: TOON_FRAG,
vertexColors: true,
fog: true,
side: THREE.DoubleSide, // palm fronds are single-sided strips
uniforms: {
...fogUniforms(),
uTime: { value: 0 },
uSwayAmp: { value: overrides.swayAmp ?? 0 },
uPalmHeight: { value: overrides.palmHeight ?? 2.6 },
uSunDir: { value: sunDir },
uSunColor: { value: new THREE.Color() },
uAmbient: { value: new THREE.Color() },
uWetLevel: { value: -10 },
uWetDarken: { value: overrides.wetDarken ?? 0.62 },
uLanternA: { value: new THREE.Vector3(FAIRY_PALMS[0].base[0], 0.5, FAIRY_PALMS[0].base[2]) },
uLanternB: { value: new THREE.Vector3(FAIRY_PALMS[1].base[0], 0.45, FAIRY_PALMS[1].base[2]) },
uLanternGlow: { value: 0 },
},
})
toonMats.push(m)
return m
}
function buildScene() {
const canvas = canvasRef.value!
renderer = new THREE.WebGLRenderer({ canvas, antialias: true })
scene = new THREE.Scene()
fog = new THREE.Fog(0x000000, 14, 55)
scene.fog = fog
camera = new THREE.PerspectiveCamera(45, 1, 0.1, 130)
camera.position.copy(camPos)
camera.lookAt(camLook)
skyMat = new THREE.ShaderMaterial({
vertexShader: SKY_VERT,
fragmentShader: SKY_FRAG,
side: THREE.BackSide,
depthWrite: false,
uniforms: {
uHorizon: { value: new THREE.Color() },
uMid: { value: new THREE.Color() },
uZenith: { value: new THREE.Color() },
uSunDir: { value: sunDir },
uSunColor: { value: new THREE.Color() },
uStars: { value: 0 },
uOvercast: { value: 0 },
uFlash: { value: 0 },
},
})
const sky = new THREE.Mesh(new THREE.SphereGeometry(60, 32, 16), skyMat)
scene.add(sky)
const small = (wrapRef.value?.clientWidth ?? 1000) < 640
const waterGeo = new THREE.PlaneGeometry(90, 48, small ? 80 : 128, small ? 56 : 96)
waterGeo.rotateX(-Math.PI / 2)
waterMat = new THREE.ShaderMaterial({
vertexShader: WATER_VERT,
fragmentShader: WATER_FRAG,
fog: true,
uniforms: {
...fogUniforms(),
uTime: { value: 0 },
uTide: { value: 0 },
uSurge: { value: 0 },
uWaveAmp: { value: cur.waveAmp },
uWaveSpeed: { value: cur.waveSpeed },
uChop: { value: cur.chop },
uShallow: { value: new THREE.Color("#8fe8d8") },
uDeep: { value: new THREE.Color("#3596d6") },
uFoamColor: { value: new THREE.Color("#fff8ec") },
uSkyTint: { value: new THREE.Color() },
uSunDir: { value: sunDir },
uSunColor: { value: new THREE.Color() },
uAmbient: { value: new THREE.Color() },
uOvercast: { value: 0 },
uShoreZ0: { value: SHORE_Z0 },
uShoreSlope: { value: SHORE_SLOPE },
},
})
const water = new THREE.Mesh(waterGeo, waterMat)
water.position.z = -16 // every edge sits beyond the fog, no visible rim
scene.add(water)
// rain: one LineSegments; the box also covers the deck terrace behind
const N = 700
const pos = new Float32Array(N * 2 * 3)
const off = new Float32Array(N * 2)
const end = new Float32Array(N * 2)
for (let i = 0; i < N; i++) {
const x = (Math.random() - 0.5) * 24
const y = Math.random() * 7
const z = 16 - Math.random() * 26
for (let v = 0; v < 2; v++) {
pos.set([x, y, z], (i * 2 + v) * 3)
off[i * 2 + v] = Math.random() * 7
end[i * 2 + v] = v
}
}
const rainGeo = new THREE.BufferGeometry()
rainGeo.setAttribute("position", new THREE.BufferAttribute(pos, 3))
rainGeo.setAttribute("aOffset", new THREE.BufferAttribute(off, 1))
rainGeo.setAttribute("aEnd", new THREE.BufferAttribute(end, 1))
rainMat = new THREE.ShaderMaterial({
vertexShader: RAIN_VERT,
fragmentShader: RAIN_FRAG,
transparent: true,
depthWrite: false,
uniforms: { uTime: { value: 0 }, uRain: { value: 0 }, uWind: { value: 0.06 } },
})
rainMesh = new THREE.LineSegments(rainGeo, rainMat)
rainMesh.visible = false
rainMesh.frustumCulled = false
scene.add(rainMesh)
// lightning bolt: a jagged ribbon of independent quads, regenerated per
// strike — thick enough to read at distance, additive so it burns bright
boltMat = new THREE.MeshBasicMaterial({
color: 0xdceaff, transparent: true, opacity: 0,
blending: THREE.AdditiveBlending, depthWrite: false,
side: THREE.DoubleSide, fog: false,
})
const MAXSEG = 18
const boltGeo = new THREE.BufferGeometry()
boltGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(MAXSEG * 4 * 3), 3))
const bIdx = new Uint16Array(MAXSEG * 6)
for (let s = 0; s < MAXSEG; s++) {
const v = s * 4
bIdx.set([v, v + 1, v + 2, v + 1, v + 3, v + 2], s * 6)
}
boltGeo.setIndex(new THREE.BufferAttribute(bIdx, 1))
boltLine = new THREE.Mesh(boltGeo, boltMat)
boltLine.name = "Bolt"
boltLine.visible = false
boltLine.frustumCulled = false
scene.add(boltLine)
// gulls: shared chevron geometry, one mesh per bird on a circular path
birdMat = new THREE.ShaderMaterial({
vertexShader: BIRD_VERT,
fragmentShader: BIRD_FRAG,
fog: true,
side: THREE.DoubleSide,
transparent: true,
uniforms: {
...fogUniforms(),
uTime: { value: 0 },
uColor: { value: new THREE.Color("#4a4f58") },
uOpacity: { value: 0 },
},
})
const birdGeo = new THREE.BufferGeometry()
birdGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array([
-0.62, 0, 0.16, 0, 0, 0, 0, 0, 0.26,
0.62, 0, 0.16, 0, 0, 0.26, 0, 0, 0,
]), 3))
for (let i = 0; i < 5; i++) {
const mesh = new THREE.Mesh(birdGeo, birdMat)
birds.push({
mesh,
cx: -6 + Math.random() * 12,
cy: 5 + Math.random() * 2.5,
cz: -14 + Math.random() * 5,
r: 2.5 + Math.random() * 3,
speed: 0.25 + Math.random() * 0.2,
theta: Math.random() * Math.PI * 2,
})
scene.add(mesh)
}
// aura orbs floating over the deck terrace
const AN = 8
const aPos = new Float32Array(AN * 3)
const aSeed = new Float32Array(AN)
for (let i = 0; i < AN; i++) {
aPos.set([
Math.random() * 5.4 - 0.2,
1.5 + Math.random() * 1.2,
9.6 + Math.random() * 6,
], i * 3)
aSeed[i] = Math.random() * 10
}
const auraGeo = new THREE.BufferGeometry()
auraGeo.setAttribute("position", new THREE.BufferAttribute(aPos, 3))
auraGeo.setAttribute("aSeed", new THREE.BufferAttribute(aSeed, 1))
auraMat = new THREE.ShaderMaterial({
vertexShader: AURA_VERT,
fragmentShader: AURA_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: { uTime: { value: 0 }, uNight: { value: 0 } },
})
const aura = new THREE.Points(auraGeo, auraMat)
aura.frustumCulled = false
scene.add(aura)
// fireflies around the beds and the beach grass line
const FN = 16
const fpos = new Float32Array(FN * 3)
const fseed = new Float32Array(FN)
for (let i = 0; i < FN; i++) {
fpos.set([
-3 + Math.random() * 10,
0.9 + Math.random() * 1.1 + 0.63,
5.5 + Math.random() * 8,
], i * 3)
fseed[i] = Math.random() * 10
}
const fgeo = new THREE.BufferGeometry()
fgeo.setAttribute("position", new THREE.BufferAttribute(fpos, 3))
fgeo.setAttribute("aSeed", new THREE.BufferAttribute(fseed, 1))
fireflyMat = new THREE.ShaderMaterial({
vertexShader: FIREFLY_VERT,
fragmentShader: FIREFLY_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: { uTime: { value: 0 }, uNight: { value: 0 } },
})
const fireflies = new THREE.Points(fgeo, fireflyMat)
fireflies.frustumCulled = false
scene.add(fireflies)
loadModel()
if (false) (window as unknown as { __beachScene?: THREE.Scene }).__beachScene = scene
}
const CLOUD_SLOTS: [number, number, number, number][] = [
// x, y, z, drift speed — first two are the sunny-day clouds
[-13, 7.5, -17, 0.22], [7, 8.5, -19, 0.16],
[-4, 7, -15, 0.28], [13, 8, -16, 0.19], [-17, 9, -14, 0.25], [2, 9.5, -20, 0.13],
]
function loadModel() {
new GLTFLoader().load("/beach/beach.glb", (gltf) => {
let cloudIdx = 0
const meshes: THREE.Mesh[] = []
gltf.scene.traverse((n) => { if ((n as THREE.Mesh).isMesh) meshes.push(n as THREE.Mesh) })
for (const mesh of meshes) {
const name = mesh.name
if (name === "Sand") {
sandMat = makeToon()
mesh.material = sandMat
} else if (name.startsWith("Palm") || name.startsWith("Flora")) {
mesh.geometry.computeBoundingBox()
const h = Math.max(0.8, mesh.geometry.boundingBox!.max.y)
const m = makeToon({ swayAmp: 0.02, palmHeight: h })
;(name.startsWith("Flora") ? floraMats : palmMats).push(m)
mesh.material = m
} else if (name === "Props" || name.startsWith("Deck")) {
mesh.material = makeToon()
} else if (name.startsWith("Cloud")) {
const slot = CLOUD_SLOTS[cloudIdx % CLOUD_SLOTS.length]
const mat = new THREE.MeshBasicMaterial({ transparent: true, opacity: 0, fog: true })
mesh.material = mat
mesh.position.set(slot[0], slot[1], slot[2])
const s = 2.2 + Math.random() * 0.8
mesh.scale.set(s, s * 0.85, s)
clouds.push({ mesh, mat, speed: slot[3], baseOpacity: 0.92, baseScale: s, baseY: slot[1] })
cloudIdx++
}
scene.add(mesh)
}
buildFairyLights(meshes)
loading.value = false
if (reduced) renderOnce()
}, undefined, () => { loading.value = false }) // scene still works without the GLB
}
// wrap every palm in fairy lights by tracing its actual trunk out of the
// mesh: walk up in slices, keep only verts near the previous slice centre
// (fronds and coconuts are far away and get ignored), then lay a helix
// around the traced spine
function buildFairyLights(meshes: THREE.Mesh[]) {
const pts: number[] = []
const seeds: number[] = []
const bases: number[] = []
const hs: number[] = []
const amps: number[] = []
for (const mesh of meshes) {
const name = mesh.name
const isPalm = name.startsWith("Palm")
const isFlora = name.startsWith("Flora_Palm")
if (!isPalm && !isFlora) continue
const amp = isFlora ? 0.028 : 0.055
const geo = mesh.geometry
const pos = geo.attributes.position as THREE.BufferAttribute
geo.computeBoundingBox()
const maxY = geo.boundingBox!.max.y
const off = mesh.position
const SL = 26
const spine: { y: number; x: number; z: number; r: number }[] = []
let cx = 0, cz = 0
let misses = 0
let seeded = false
for (let s = 0; s < SL; s++) {
const y0 = (s / SL) * maxY
const y1 = ((s + 1) / SL) * maxY
let sx = 0, sz = 0, n = 0
for (let i = 0; i < pos.count; i++) {
const y = pos.getY(i)
if (y < y0 || y >= y1) continue
const x = pos.getX(i), z = pos.getZ(i)
if (seeded && Math.hypot(x - cx, z - cz) > 0.3) continue
sx += x; sz += z; n++
}
if (n < 4) {
// trunk rings are sparser than the slices — skip gaps, and only
// give up after several misses in a row (that is the crown)
if (seeded && ++misses > 2) break
continue
}
misses = 0
seeded = true
cx = sx / n
cz = sz / n
let r = 0
for (let i = 0; i < pos.count; i++) {
const y = pos.getY(i)
if (y < y0 || y >= y1) continue
const d = Math.hypot(pos.getX(i) - cx, pos.getZ(i) - cz)
if (d < 0.3 && d > r) r = d
}
spine.push({ y: (y0 + y1) / 2, x: cx, z: cz, r })
}
if (spine.length < 6) continue
const N = 42
const top = spine.length - 2
for (let k = 0; k < N; k++) {
const f = 1 + (k / (N - 1)) * (top - 1) * 0.96
const i0 = Math.min(Math.floor(f), top)
const i1 = Math.min(i0 + 1, top)
const ft = f - i0
const s0 = spine[i0], s1 = spine[i1]
const y = s0.y + (s1.y - s0.y) * ft
const sxc = s0.x + (s1.x - s0.x) * ft
const szc = s0.z + (s1.z - s0.z) * ft
const rr = s0.r + (s1.r - s0.r) * ft + 0.035
const ang = (y / maxY) * 26 + off.x * 3.1
pts.push(off.x + sxc + Math.cos(ang) * rr, off.y + y, off.z + szc + Math.sin(ang) * rr)
seeds.push(Math.random() * 10)
bases.push(off.x, off.y, off.z)
hs.push(maxY)
amps.push(amp)
}
}
if (!pts.length) return
const fairyGeo = new THREE.BufferGeometry()
fairyGeo.setAttribute("position", new THREE.BufferAttribute(new Float32Array(pts), 3))
fairyGeo.setAttribute("aSeed", new THREE.BufferAttribute(new Float32Array(seeds), 1))
fairyGeo.setAttribute("aBase", new THREE.BufferAttribute(new Float32Array(bases), 3))
fairyGeo.setAttribute("aH", new THREE.BufferAttribute(new Float32Array(hs), 1))
fairyGeo.setAttribute("aAmp", new THREE.BufferAttribute(new Float32Array(amps), 1))
fairyLightMat = new THREE.ShaderMaterial({
vertexShader: FAIRY_VERT,
fragmentShader: FAIRY_FRAG,
transparent: true,
depthWrite: false,
blending: THREE.AdditiveBlending,
uniforms: {
uTime: { value: 0 },
uSway: { value: 0 },
uNight: { value: 0 },
},
})
fairyLights = new THREE.Points(fairyGeo, fairyLightMat)
fairyLights.frustumCulled = false
scene.add(fairyLights)
}
function regenBolt() {
const pos = boltLine.geometry.getAttribute("position") as THREE.BufferAttribute
let x = (Math.random() - 0.5) * 22
// most strikes stay out at sea; every third one hits right off the beach
let z = Math.random() < 0.3 ? -8 - Math.random() * 4 : -13 - Math.random() * 5
let y = 13
boltX = x
boltZ = z
let seg = 0
// one camera-facing quad per segment, tapering toward the ground
const put = (ax: number, ay: number, az: number, bx: number, by: number, bz: number, w: number) => {
const v = seg * 4
pos.setXYZ(v, ax - w, ay, az)
pos.setXYZ(v + 1, ax + w, ay, az)
pos.setXYZ(v + 2, bx - w * 0.85, by, bz)
pos.setXYZ(v + 3, bx + w * 0.85, by, bz)
seg++
}
let branchAt: [number, number, number] | null = null
for (let s = 0; s < 13 && y > 0; s++) {
const nx = x + (Math.random() - 0.5) * 1.5
const nz = z + (Math.random() - 0.5) * 1.0
const ny = y - (0.7 + Math.random() * 0.9)
put(x, y, z, nx, Math.max(ny, 0), nz, 0.3 * (0.35 + 0.65 * (y / 13)))
if (s === 5) branchAt = [nx, ny, nz]
x = nx; y = ny; z = nz
}
if (branchAt) {
let [bx, by, bz] = branchAt
for (let s = 0; s < 4 && by > 1; s++) {
const nx = bx + 0.5 + Math.random() * 0.9
const ny = by - (0.6 + Math.random() * 0.7)
const nz = bz + (Math.random() - 0.5)
put(bx, by, bz, nx, ny, nz, 0.08)
bx = nx; by = ny; bz = nz
}
}
boltLine.geometry.setDrawRange(0, seg * 6)
pos.needsUpdate = true
}
/* ── per-frame simulation ── */
function updateFrame(dt: number) {
elapsed += dt
const target = PRESETS[props.weather] ?? PRESETS.sunny
const k = 1 - Math.exp(-dt / 0.7)
cur.overcast += (target.overcast - cur.overcast) * k
cur.clouds += (target.clouds - cur.clouds) * k
cur.waveAmp += (target.waveAmp - cur.waveAmp) * k
cur.waveSpeed += (target.waveSpeed - cur.waveSpeed) * k
cur.chop += (target.chop - cur.chop) * k
cur.sway += (target.sway - cur.sway) * k
cur.lightDim += (target.lightDim - cur.lightDim) * k
cur.fogFar += (target.fogFar - cur.fogFar) * k
cur.rain += (target.rain - cur.rain) * k
cur.lightningRate += (target.lightningRate - cur.lightningRate) * k
cur.cloudTint.lerp(target.cloudTint, k)
const stormF = THREE.MathUtils.smoothstep(cur.waveAmp, 0.12, 0.28)
// sun path: rises ~6h, sets ~20h, arcs over the ocean (-z)
const h = ((props.timeOfDay % 24) + 24) % 24
paletteAt(h)
const t = (h - 6) / 14
const el = Math.sin(Math.PI * THREE.MathUtils.clamp(t, -0.12, 1.12)) * 1.05
// azimuth stays inside the camera frustum so sunrise and sunset happen on screen
const az = THREE.MathUtils.lerp(-0.9, 0.4, t)
sunDir.set(Math.sin(az) * Math.cos(el), Math.sin(el), -Math.cos(az) * Math.cos(el)).normalize()
// lightning: random flicker envelope, sky flash + ambient boost
let flash = 0
if (strikeT >= 0) {
strikeT += dt
const ph = strikeT / strikeDur
if (ph >= 1) strikeT = -1
else flash = Math.sin(Math.PI * ph) * (0.55 + 0.45 * Math.sin(strikeT * 87.0)) * 0.9
} else if (cur.lightningRate > 0.1) {
nextStrike -= dt
if (nextStrike <= 0) {
strikeT = 0
strikeDur = 0.45 + Math.random() * 0.4
nextStrike = (60 / cur.lightningRate) * (0.5 + Math.random())
regenBolt()
// closeness drives the thunder: near strikes crack, far ones rumble.
// calibrated to the actual spawn range (roughly 17..30m from the camera)
const bdist = Math.hypot(boltX - camPos.x, boltZ - camPos.z)
emit("lightning", THREE.MathUtils.clamp(1 - (bdist - 18) / 12, 0.1, 1))
}
} else {
nextStrike = 2 + Math.random() * 3
}
boltLine.visible = strikeT >= 0
boltMat.opacity = strikeT >= 0 ? Math.min(1, 0.5 + flash * 1.2) : 0
sunColor.copy(pal.sun).multiplyScalar(pal.sunI * cur.lightDim)
ambient.copy(pal.amb).addScalar(flash * 0.45)
// gulls exist where you would hear them: friendly weather, daylight
const gullF = Math.max(0, 1 - cur.overcast * 1.15) * THREE.MathUtils.smoothstep(el, 0.02, 0.2)
const nightF = 1 - THREE.MathUtils.smoothstep(el, -0.04, 0.14)
// fairy lights come on at dusk; their soft pools warm the palm feet
const fairyF = 1 - THREE.MathUtils.smoothstep(el, 0.02, 0.28)
if (fairyLightMat) {
fairyLightMat.uniforms.uTime.value = elapsed
fairyLightMat.uniforms.uSway.value = cur.sway
fairyLightMat.uniforms.uNight.value = fairyF
}
if (fairyLights) fairyLights.visible = fairyF > 0.02
auraMat.uniforms.uTime.value = elapsed
auraMat.uniforms.uNight.value = fairyF * (1 - cur.rain * 0.6)
const lampGlow = fairyF * 0.3 * (0.92 + 0.08 * Math.sin(elapsed * 2.3))
// fireflies come out on calm nights only
fireflyMat.uniforms.uTime.value = elapsed
fireflyMat.uniforms.uNight.value = nightF * (1 - cur.rain) * (1 - stormF)
birdMat.uniforms.uTime.value = elapsed
birdMat.uniforms.uOpacity.value = gullF * 0.9
birdMat.uniforms.fogColor.value.copy(fog.color)
birdMat.uniforms.fogFar.value = cur.fogFar
for (const b of birds) {
b.theta += b.speed * dt
b.mesh.position.set(
b.cx + Math.cos(b.theta) * b.r,
b.cy + Math.sin(b.theta * 2.3) * 0.4,
b.cz + Math.sin(b.theta) * b.r,
)
b.mesh.rotation.y = -b.theta
b.mesh.visible = gullF > 0.2
}
// audio follows the visuals: report the crossfaded state a few times a second
mixT += dt
if (mixT > 0.15) {
mixT = 0
emit("mix", {
waves: THREE.MathUtils.clamp(0.55 + (cur.waveAmp - 0.05) * 2.0, 0.55, 1),
rain: cur.rain,
gulls: gullF,
storm: stormF,
night: nightF,
})
}
// tide: slow 3.5 min swell; wet sand trails the retreating waterline
const tide = 0.15 * Math.sin((2 * Math.PI * elapsed) / 210)
// swash train: two overlapped swells, shaped so each wave runs up and drains
const s1 = Math.sin((2 * Math.PI * elapsed) / 5.2)
const s2 = Math.sin((2 * Math.PI * elapsed) / 2.3 + 1.7)
const surge = cur.waveAmp * 1.5 * Math.pow(Math.max(0, 0.62 * s1 + 0.38 * s2), 1.4)
const waterEdgeY = tide + surge + cur.waveAmp * 0.3
wetLevel = Math.max(waterEdgeY, wetLevel - 0.015 * dt)
// fog color = the sky shader's horizon after its overcast mix, so the far
// water edge melts into the sky without a seam
const g = ((pal.horizon.r + pal.horizon.g + pal.horizon.b) / 3) * 0.72
cScratch.setRGB(g + pal.horizon.r * 0.1, g + pal.horizon.g * 0.1, g + pal.horizon.b * 0.1)
cScratch.lerpColors(pal.horizon, cScratch, cur.overcast * 0.8)
.multiplyScalar(1 - 0.58 * cur.overcast * cur.overcast)
.addScalar(flash)
fog.color.copy(cScratch)
fog.far = cur.fogFar
renderer!.setClearColor(cScratch)
const su = skyMat.uniforms
su.uHorizon.value.copy(pal.horizon)
su.uMid.value.copy(pal.mid)
su.uZenith.value.copy(pal.zenith)
su.uSunColor.value.copy(pal.sun).multiplyScalar(Math.min(pal.sunI + 0.15, 1.2))
su.uStars.value = pal.stars
su.uOvercast.value = cur.overcast
su.uFlash.value = flash
const wu = waterMat.uniforms
wu.uTime.value = elapsed
wu.uTide.value = tide
wu.uSurge.value = surge
wu.uWaveAmp.value = cur.waveAmp
wu.uWaveSpeed.value = cur.waveSpeed
wu.uChop.value = cur.chop
wu.uSunColor.value.copy(sunColor)
wu.uAmbient.value.copy(ambient)
wu.uSkyTint.value.copy(cScratch)
wu.uOvercast.value = cur.overcast
wu.fogColor.value.copy(fog.color)
wu.fogFar.value = cur.fogFar
for (const m of toonMats) {
m.uniforms.uTime.value = elapsed
m.uniforms.uSunColor.value.copy(sunColor)
m.uniforms.uAmbient.value.copy(ambient)
m.uniforms.uLanternGlow.value = lampGlow
m.uniforms.fogColor.value.copy(fog.color)
m.uniforms.fogFar.value = cur.fogFar
}
if (sandMat) sandMat.uniforms.uWetLevel.value = wetLevel
for (const m of palmMats) m.uniforms.uSwayAmp.value = 0.055 * cur.sway
for (const m of floraMats) m.uniforms.uSwayAmp.value = 0.028 * cur.sway
// camera drifts between the beach pose and the deck bench
const vt = CAM_VIEWS[props.view] ?? CAM_VIEWS.beach
const kc = 1 - Math.exp(-dt / 0.9)
camPos.lerp(vt.pos, kc)
camLook.lerp(vt.look, kc)
camera.position.copy(camPos)
camera.lookAt(camLook)
// clouds drift and fade per weather; a storm deck hangs lower, swells
// bigger and races — dim with the ambient light
clouds.forEach((c, i) => {
c.mesh.position.x += c.speed * (1 + stormF * 2.5) * dt
if (c.mesh.position.x > 21) c.mesh.position.x = -21
const want = i < Math.round(cur.clouds) ? c.baseOpacity : 0
c.mat.opacity += (want - c.mat.opacity) * k
c.mesh.visible = c.mat.opacity > 0.02
const ts = c.baseScale * (1 + stormF * 0.7)
c.mesh.scale.x += (ts - c.mesh.scale.x) * k
c.mesh.scale.y += (ts * 0.85 - c.mesh.scale.y) * k
c.mesh.scale.z += (ts - c.mesh.scale.z) * k
c.mesh.position.y += (c.baseY - stormF * 1.1 - c.mesh.position.y) * k
c.mat.color.copy(cur.cloudTint).multiply(cScratch.copy(ambient).multiplyScalar(1.2).addScalar(0.25))
})
rainMat.uniforms.uTime.value = elapsed
rainMat.uniforms.uRain.value = cur.rain
rainMat.uniforms.uWind.value = 0.04 + stormF * 0.22
rainMesh.visible = cur.rain > 0.02
}
/* ── loop / lifecycle ── */
let last = 0
function loop(now: number) {
const dt = Math.min((now - last) / 1000, 0.05)
last = now
updateFrame(dt)
renderer!.render(scene, camera)
raf = requestAnimationFrame(loop)
}
function start() {
if (running || !renderer) return
running = true
last = performance.now()
raf = requestAnimationFrame(loop)
}
function stop() {
running = false
cancelAnimationFrame(raf)
}
function renderOnce() {
if (!renderer) return
// snap the crossfade + camera so a static frame shows the target state
Object.assign(cur, { ...PRESETS[props.weather], cloudTint: cur.cloudTint.copy(PRESETS[props.weather].cloudTint) })
const vt = CAM_VIEWS[props.view] ?? CAM_VIEWS.beach
camPos.copy(vt.pos)
camLook.copy(vt.look)
updateFrame(1 / 60)
renderer.render(scene, camera)
}
function updateSize() {
const wrap = wrapRef.value
if (!wrap || !renderer) return
const w = wrap.clientWidth, hgt = wrap.clientHeight
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
renderer.setSize(w, hgt, false)
camera.aspect = w / hgt
camera.updateProjectionMatrix()
if (reduced) renderOnce()
}
watch(() => [props.weather, props.timeOfDay, props.view], () => {
if (reduced) renderOnce()
})
watch(() => props.paused, (p) => {
if (reduced) return
if (p) stop()
else start()
})
onMounted(() => {
buildScene()
updateSize()
window.addEventListener("resize", updateSize)
reduced = window.matchMedia("(prefers-reduced-motion: reduce)").matches
if (reduced) {
renderOnce()
return
}
io = new IntersectionObserver(
([entry]) => (entry.isIntersecting ? start() : stop()),
{ threshold: 0.1 },
)
io.observe(wrapRef.value!)
})
onBeforeUnmount(() => {
io?.disconnect()
stop()
window.removeEventListener("resize", updateSize)
scene?.traverse((n) => {
const mesh = n as THREE.Mesh
if (mesh.isMesh) {
mesh.geometry?.dispose()
const mats = Array.isArray(mesh.material) ? mesh.material : [mesh.material]
mats.forEach((m) => m?.dispose())
}
})
renderer?.dispose()
renderer?.forceContextLoss()
renderer = null
})
</script>
<style scoped>
.beach-wrapper {
position: relative;
width: 100%;
height: 100%;
overflow: hidden;
}
.beach-canvas {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
display: block;
}
.beach-loading {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
letter-spacing: 0.12em;
color: rgba(240, 236, 227, 0.8);
pointer-events: none;
}
.beach-caret {
animation: beach-blink 1s steps(2) infinite;
}
@keyframes beach-blink {
50% { opacity: 0; }
}
@media (prefers-reduced-motion: reduce) {
.beach-caret { animation: none; }
}
</style>