From 6a2b0d70eff280460d48f7535085ce1037a49d42 Mon Sep 17 00:00:00 2001 From: Cameron Date: Sat, 26 Sep 2026 01:45:44 -0700 Subject: [PATCH] Let the tide pool's floor be seen through its water: the solid scene is drawn first and copied with its depth, and the water shows it shifted where the surface slopes, so the floor, the fish and anything standing in the water sway under the ripples; it switches itself off on phones and whenever frames run slow. --- public/tide-pool.js | 86 +++++++++++++++++++++++++++++------- src/components/tide-pool.tsx | 4 +- 2 files changed, 72 insertions(+), 18 deletions(-) diff --git a/public/tide-pool.js b/public/tide-pool.js index a175c90..1eaf069 100644 --- a/public/tide-pool.js +++ b/public/tide-pool.js @@ -57,7 +57,8 @@ async function initialize() { scene.fog = null; const camera = new T.OrthographicCamera(-6, 6, 6, -6, 0.1, 140); // Post-processing, in as few passes as it can be, since every pass costs a fixed amount on top of its pixels. The scene - // is drawn into a float image. Its brightest parts are gathered at a quarter of the size, which the graphics card then + // is drawn into a float image, all at once, or, where the graphics card has room for it, everything solid first, then a + // copy of that for the water to be seen through, then the water and the glows over it. Its brightest parts are gathered at a quarter of the size, which the graphics card then // halves again and again down to a single pixel in one step, and one small pass reads a glow back out of those // halvings, near and far, so lamps, eyes and glints bleed softly past their edges. The miniature's blur runs across the // image; the last pass, straight to the screen, runs it down, adds the glow, tone maps, and draws the veil's ring while @@ -77,7 +78,13 @@ async function initialize() { }`; const post = renderer.extensions.has('EXT_color_buffer_float') || renderer.extensions.has('EXT_color_buffer_half_float') ? (() => { const target = (options = {}) => new T.WebGLRenderTarget(1, 1, { type: T.HalfFloatType, depthBuffer: false, ...options }); - const sceneTarget = target({ depthBuffer: true }), blurTarget = target(); + const sceneTarget = target({ depthBuffer: true }), blurTarget = target(), solidTarget = target(); + // The water and the glows over it, a scene of their own while the solid one is to be seen through them. + const overlay = new T.Scene(), hosted = []; + let seeing = false; + // What the water needs to be seen through: the solid scene, and how far away each pixel of it lies. + const seeThrough = { uRefract: { value: solidTarget.texture }, uProject: { value: new T.Matrix4() }, uScreen: { value: new T.Vector2() }, + uDepthRange: { value: new T.Vector3(camera.near, camera.far, 50) }, uRefractOn: { value: 0 } }; const brightTarget = target({ generateMipmaps: true, minFilter: T.LinearMipmapLinearFilter }), glowTarget = target(); const tilt = { uTexel: { value: new T.Vector2() }, uAmount: { value: 2 }, uFocus: { value: .5 }, uBand: { value: .13 }, uRamp: { value: .34 } }; const bright = new T.ShaderMaterial({ @@ -111,6 +118,14 @@ async function initialize() { gl_FragColor = vec4((level(0.) * .56 + level(2.) * .58 + level(3.5) * .6 + level(5.) * .62 + level(6.) * .64) * uStrength, 1.); }`, }); + const solid = new T.ShaderMaterial({ + uniforms: { tColor: { value: sceneTarget.texture }, tDepth: { value: null }, uDepthRange: seeThrough.uDepthRange }, + vertexShader: FULL, + fragmentShader: ` + uniform sampler2D tColor, tDepth; uniform vec3 uDepthRange; varying vec2 vUv; + // The colour, and how far away it lies. + void main() { gl_FragColor = vec4(texture2D(tColor, vUv).rgb, uDepthRange.x + texture2D(tDepth, vUv).r * (uDepthRange.y - uDepthRange.x) - uDepthRange.z); }`, + }); const across = new T.ShaderMaterial({ uniforms: { tScene: { value: sceneTarget.texture }, ...tilt }, vertexShader: FULL, @@ -150,19 +165,40 @@ async function initialize() { const flat = new T.OrthographicCamera(-1, 1, 1, -1, 0, 1), size = new T.Vector2(); const pass = (material, target) => { quad.material = material; renderer.setRenderTarget(target); renderer.render(quad, flat); }; return { - tilt, final, + tilt, final, seeThrough, + host(object) { hosted.push(object); (seeing ? overlay : scene).add(object); }, + // Seeing through the water costs a copy of the whole image and a second pass over it, so it is dropped first + // when frames run slow. + see(on) { + if (on === seeing) return; + seeing = on; + sceneTarget.depthTexture?.dispose(); + sceneTarget.depthTexture = on ? new T.DepthTexture(sceneTarget.width, sceneTarget.height) : null; + sceneTarget.dispose(); if (!on) solidTarget.dispose(); + solid.uniforms.tDepth.value = sceneTarget.depthTexture; + seeThrough.uRefractOn.value = on ? 1 : 0; + for (const object of hosted) (on ? overlay : scene).add(object); + }, resize() { renderer.getDrawingBufferSize(size); const w = Math.max(1, size.x), h = Math.max(1, size.y), qw = Math.max(1, Math.ceil(w / 4)), qh = Math.max(1, Math.ceil(h / 4)); - sceneTarget.setSize(w, h); blurTarget.setSize(w, h); brightTarget.setSize(qw, qh); glowTarget.setSize(qw, qh); - tilt.uTexel.value.set(1 / w, 1 / h); glow.uniforms.uTexel.value.set(1 / qw, 1 / qh); + sceneTarget.setSize(w, h); blurTarget.setSize(w, h); solidTarget.setSize(w, h); + brightTarget.setSize(qw, qh); glowTarget.setSize(qw, qh); + tilt.uTexel.value.set(1 / w, 1 / h); glow.uniforms.uTexel.value.set(1 / qw, 1 / qh); seeThrough.uScreen.value.set(1 / w, 1 / h); }, render() { renderer.setRenderTarget(sceneTarget); renderer.render(scene, camera); + if (seeing) { + seeThrough.uProject.value.multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); + pass(solid, solidTarget); + renderer.setRenderTarget(sceneTarget); renderer.autoClear = false; renderer.render(overlay, camera); renderer.autoClear = true; + } pass(bright, brightTarget); pass(glow, glowTarget); pass(across, blurTarget); pass(final, null); }, }; })() : null; + // The water and the glows over it go where the post-processing wants them. + const host = object => post ? post.host(object) : scene.add(object); // Light follows a slow day. Eyes and visors are unlit, so they read as signal lamps at night. // Image-based light: a generated violet dusk sky, prefiltered into an environment map that lights and reflects on every surface. @@ -648,9 +684,10 @@ async function initialize() { renderer.setClearColor(surfaceClear, clearAlpha); } // Water: the same grid lifted to the water surface, fading out where the water thins to nothing. - const waterMaterial = new T.ShaderMaterial({ + const WATER = { transparent: true, depthWrite: false, - uniforms: { uTime: shared.uTime, uLight: shared.uLight, uSun: shared.uSun, uView: shared.uView, + uniforms: { ...(post?.seeThrough ?? { uRefract: { value: level }, uProject: { value: new T.Matrix4() }, uScreen: { value: new T.Vector2(1, 1) }, + uDepthRange: { value: new T.Vector3(.1, 140, 50) }, uRefractOn: { value: 0 } }), uTime: shared.uTime, uLight: shared.uLight, uSun: shared.uSun, uView: shared.uView, uSurface: shared.uSurface, uSurfaceTexel: { value: surfaceTexel }, uFine: { value: 1 / FINE }, uStep: { value: STEP }, uGrid: shared.uGrid, uGridBox: shared.uGridBox, uFlow: shared.uFlow, uOpen: { value: 0 }, uTide: shared.uWater, uLamps: shared.uLamps, uLampColor: shared.uLampColor, uNight: shared.uNight, uDusk: shared.uDusk, uStorm: shared.uStorm, @@ -678,6 +715,7 @@ async function initialize() { uniform sampler2D uPlankton, uSurface; uniform vec2 uSurfaceTexel; uniform float uMoon, uFine, uStep; uniform sampler2D uFlow; uniform vec4 uGridBox; uniform float uOpen; uniform vec4 uSwell[3]; uniform vec3 uSwellPhase; uniform float uSwellLift; + uniform sampler2D uRefract; uniform mat4 uProject; uniform vec2 uScreen; uniform vec3 uDepthRange; uniform float uRefractOn; float foamHash(vec2 p) { return fract(sin(dot(p, vec2(127.1, 311.7))) * 43758.5453); } float foamNoise(vec2 p) { vec2 i = floor(p), f = p - i; f = f * f * (3. - 2. * f); @@ -779,17 +817,31 @@ async function initialize() { vec3 linear = pow(max(col, 0.), vec3(2.2)); // Toward the far edge of the view the water recedes into the same faint haze as the shelf. float hz = smoothstep(uHazeZoom * .25, uHazeZoom * 1.9, dot(vW.xz - uHaze.zw, uHaze.xy)) * .5; - gl_FragColor = vec4(mix(linear, uHazeColor, hz), clamp(alpha + hz * .3, 0., .94) * smoothstep(.012, .04, d)); + float cover = clamp(alpha + hz * .3, 0., .94), fade = smoothstep(.012, .04, d); + vec3 surface = mix(linear, uHazeColor, hz); + if (uRefractOn > 0.) { + // Where this pixel lies on the image and how far it is from the eye. + vec2 sc = gl_FragCoord.xy * uScreen; + float eye = uDepthRange.x + gl_FragCoord.z * (uDepthRange.y - uDepthRange.x) - uDepthRange.z; + // Seen through the water, the floor bends: shifted where the surface slopes, as far as a ray through the slope is + // turned aside on its way down to the floor, so it swims under the ripples. It is only ever taken from what lies + // beyond the water, never from something standing in front of it. + vec2 bend = g * min(d, 1.2) * .5; + vec4 through = texture2D(uRefract, sc + (uProject * vec4(bend.x, 0., bend.y, 0.)).xy * .5); + if (through.a < eye) through = texture2D(uRefract, sc); + gl_FragColor = vec4(mix(through.rgb, surface, cover), fade); + } else gl_FragColor = vec4(surface, cover * fade); }`, - }); + }; + const waterMaterial = new T.ShaderMaterial(WATER); const water = new T.Mesh(grid, waterMaterial); water.frustumCulled = false; water.renderOrder = 2; - scene.add(water); + host(water); // Beyond the modelled shelf the open sea continues at the tide's own level. - const openSea = new T.Mesh(new T.PlaneGeometry(400, 200).rotateX(-Math.PI / 2).translate(0, 0, Z1 + 100 - CELL), waterMaterial.clone()); - openSea.material.uniforms = { ...waterMaterial.uniforms, uOpen: { value: 1 } }; + const openSea = new T.Mesh(new T.PlaneGeometry(400, 200).rotateX(-Math.PI / 2).translate(0, 0, Z1 + 100 - CELL), + new T.ShaderMaterial({ ...WATER, uniforms: { ...WATER.uniforms, uOpen: { value: 1 } } })); openSea.renderOrder = 2; - scene.add(openSea); + host(openSea); // Upload the simulation's grid: surface, ground, set stone, dampness. const cellCount = COLS * ROWS, gridData = gridTexture.image.data; @@ -882,7 +934,7 @@ async function initialize() { new T.MeshBasicMaterial({ map: bloomTexture, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), 8); bloomMesh.frustumCulled = false; bloomMesh.renderOrder = 5; bloomMesh.count = 0; bloomMesh.setColorAt(0, new T.Color()); - scene.add(bloomMesh); + host(bloomMesh); const bloomColor = new T.Color(), bloomBase = new T.Color(0xc9d8d0); function drawBlooms() { while (blooms.length && world.time - blooms[0].born > 9) blooms.shift(); @@ -970,7 +1022,7 @@ async function initialize() { const GLOWS = 160; const glowMesh = new T.InstancedMesh(new T.PlaneGeometry(2, 2), new T.MeshBasicMaterial({ map: bloomTexture, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), GLOWS + 4); glowMesh.frustumCulled = false; glowMesh.renderOrder = 6; glowMesh.count = 0; glowMesh.setColorAt(0, new T.Color()); - scene.add(glowMesh); + host(glowMesh); // Pools are named as they form. A name shows only when asked for: pointed at, tapped, or jumped to. const labels = new Map(); @@ -1071,6 +1123,7 @@ async function initialize() { contact: part(new T.RingGeometry(.88, 1, 40).rotateX(-Math.PI / 2), new T.MeshBasicMaterial({ color: 0xffffff, transparent: true, blending: T.AdditiveBlending, depthWrite: false }), 260, { shadow: false, colored: true }), }; + host(P.contact.mesh); const base = new T.Matrix4(), local = new T.Matrix4(); let cosA = 1, sinA = 0, bx = 0, by = 0, bz = 0; @@ -2189,7 +2242,7 @@ async function initialize() { let next = quality; if (frameTime > 1 / 42) { tooSlow = quality; slowAt = now; next = Math.max(.6, quality * (frameTime > 1 / 28 ? .8 : .9)); } else if (frameTime < 1 / 56 && (quality * 1.08 < tooSlow * .97 || now - slowAt > 30000)) next = Math.min(1, quality * 1.08); - if (Math.abs(next - quality) > .01) { quality = next; resize(); } + if (Math.abs(next - quality) > .01) { quality = next; post?.see(!mobile && quality > .95); resize(); } } function resize() { const { width, height } = stage.getBoundingClientRect(); @@ -2724,6 +2777,7 @@ async function initialize() { if (paused) world.objects.forEach(o => { o.height = 0; }); applyOptions(); + post?.see(!mobile); resize(); if (!reduced.matches) { // Open close on the busiest spot in the first pool, then draw back to show the shelf around it. diff --git a/src/components/tide-pool.tsx b/src/components/tide-pool.tsx index bf77431..1ce6768 100644 --- a/src/components/tide-pool.tsx +++ b/src/components/tide-pool.tsx @@ -178,7 +178,7 @@ export function TidePoolContent() {

Preparing the pool.

Inside the pool -

Everything here is one surface: a rocky shore that the tide floods and drains every four minutes. The water is simulated: as the tide rises the sea comes in over the rock, up the gullies first, and pours over the lip of the pool; as it falls it drains away again. A swell rolls in off the sea all the while, and its waves break into foam on the rocks, bigger in a storm. The surface is simulated too, a few centimetres at a time: every drop, swimmer and gust of wind raises ripples that spread, interfere, and bounce off the rock, and the sunlight they bend gathers into the bright, shifting net on the floor of the pool. Loose sand lies in the hollows and gullies and wherever the borers heap their spoil; fast water lifts it and slow water lets it fall, so the gullies scour, spoil heaps wash out, and a storm rearranges the shore. The tide's reach swells and shrinks with the moon, which waxes and wanes over forty minutes: springs come at full and new moon, so some low waters lay the shore bare, and the rock stays dark where the water has just left it. Full-moon nights are silver; on moonless nights the plankton, the stars in still water, and the lit windows show best. Wherever the ground dips and cannot drain as the tide falls, water stays behind, and that is a pool. There is one pool to begin with. Borers dig burrows and tunnels, masons fill hollows and raise walls, and breakers knock walls down, so the pools grow, join, drain, and form on their own. Pools are named as they appear. Artificers build structures in the water out of small parts, one at a time, each chosen from what is already around it: frames that bear load, solid plates, decks that reach out over the water, pods to live in, gears and tide wheels the tide turns, pipes, and lamps. Nothing is planned beyond the next part, so no two structures come out alike. A structure's footing is solid: the water goes around it, and the ground under it holds. Scrapers sleep in the pods, collectors keep hoards on the decks, and the tabs circle below and dart in among the frames when something startles them. Breakers work parts loose at the footing and artificers make them good again; when a footing gives way, everything it held falls. A tide wheel on a structure turns only while the water runs through it and stores its charge; the structure's lamps and windows burn at night only with a charged wheel nearby. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full, and a beacon, powered by a wheel, burns at night, feeds the plankton around it, and draws the tabs. Now and then a storm comes in off the sea: the water rises and runs rough, the waves batter whatever stands at the waterline, and the fish hide among the frames. Day turns to night every seven minutes, and the film on each floor grows only in wet light. At night the plankton glows wherever something moves through it.

+

Everything here is one surface: a rocky shore that the tide floods and drains every four minutes. The water is simulated: as the tide rises the sea comes in over the rock, up the gullies first, and pours over the lip of the pool; as it falls it drains away again. A swell rolls in off the sea all the while, and its waves break into foam on the rocks, bigger in a storm. The surface is simulated too, a few centimetres at a time: every drop, swimmer and gust of wind raises ripples that spread, interfere, and bounce off the rock, and the sunlight they bend gathers into the bright, shifting net on the floor of the pool, while the floor itself, seen through them, sways as they pass. Loose sand lies in the hollows and gullies and wherever the borers heap their spoil; fast water lifts it and slow water lets it fall, so the gullies scour, spoil heaps wash out, and a storm rearranges the shore. The tide's reach swells and shrinks with the moon, which waxes and wanes over forty minutes: springs come at full and new moon, so some low waters lay the shore bare, and the rock stays dark where the water has just left it. Full-moon nights are silver; on moonless nights the plankton, the stars in still water, and the lit windows show best. Wherever the ground dips and cannot drain as the tide falls, water stays behind, and that is a pool. There is one pool to begin with. Borers dig burrows and tunnels, masons fill hollows and raise walls, and breakers knock walls down, so the pools grow, join, drain, and form on their own. Pools are named as they appear. Artificers build structures in the water out of small parts, one at a time, each chosen from what is already around it: frames that bear load, solid plates, decks that reach out over the water, pods to live in, gears and tide wheels the tide turns, pipes, and lamps. Nothing is planned beyond the next part, so no two structures come out alike. A structure's footing is solid: the water goes around it, and the ground under it holds. Scrapers sleep in the pods, collectors keep hoards on the decks, and the tabs circle below and dart in among the frames when something startles them. Breakers work parts loose at the footing and artificers make them good again; when a footing gives way, everything it held falls. A tide wheel on a structure turns only while the water runs through it and stores its charge; the structure's lamps and windows burn at night only with a charged wheel nearby. Artificers also build working machines: a sluice gate on a pool's outlet shuts as the tide falls and holds the pool full, and a beacon, powered by a wheel, burns at night, feeds the plankton around it, and draws the tabs. Now and then a storm comes in off the sea: the water rises and runs rough, the waves batter whatever stands at the waterline, and the fish hide among the frames. Day turns to night every seven minutes, and the film on each floor grows only in wet light. At night the plankton glows wherever something moves through it.

@@ -189,7 +189,7 @@ export function TidePoolContent() {

Rendered with Three.js. Software license.

- + ); } -- 2.51.2