惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Forbes - Security
Forbes - Security
OSCHINA 社区最新新闻
OSCHINA 社区最新新闻
P
Palo Alto Networks Blog
Martin Fowler
Martin Fowler
T
Threatpost
D
Docker
S
Schneier on Security
M
MIT News - Artificial intelligence
G
Google Developers Blog
L
LINUX DO - 热门话题
J
Java Code Geeks
月光博客
月光博客
博客园 - 三生石上(FineUI控件)
IT之家
IT之家
博客园 - Franky
C
Cyber Attacks, Cyber Crime and Cyber Security
K
Kaspersky official blog
Google DeepMind News
Google DeepMind News
N
News and Events Feed by Topic
V
Vulnerabilities – Threatpost
CTFtime.org: upcoming CTF events
CTFtime.org: upcoming CTF events
人人都是产品经理
人人都是产品经理
Spread Privacy
Spread Privacy
T
Tailwind CSS Blog
爱范儿
爱范儿
阮一峰的网络日志
阮一峰的网络日志
U
Unit 42
C
CERT Recently Published Vulnerability Notes
The GitHub Blog
The GitHub Blog
Simon Willison's Weblog
Simon Willison's Weblog
NISL@THU
NISL@THU
MongoDB | Blog
MongoDB | Blog
Application and Cybersecurity Blog
Application and Cybersecurity Blog
H
Heimdal Security Blog
Recorded Future
Recorded Future
云风的 BLOG
云风的 BLOG
SecWiki News
SecWiki News
P
Privacy International News Feed
P
Proofpoint News Feed
O
OpenAI News
B
Blog
腾讯CDC
F
Full Disclosure
Apple Machine Learning Research
Apple Machine Learning Research
T
Tor Project blog
H
Hacker News: Front Page
Project Zero
Project Zero
Hugging Face - Blog
Hugging Face - Blog
C
Cisco Blogs
S
Security Affairs

Codrops

ZERO: The Engineering Behind a Defiant Interactive Narrative | Codrops Meet the Speakers of the First Three.js Conference | Codrops The Architecture Behind Trionn: Coordinating GSAP, Three.js, Lenis, and Web Audio | Codrops Where Stories Become Interactive Worlds: How makemepulse Brings Ideas to Life Across Web, XR, and Beyond | Codrops The Sleepers: Creating an Atmospheric WebGL Experience with Lightweight Techniques Building an Interactive Wave Propagation Cube Grid with Three.js | Codrops Ten Years Away: Designing an Interactive Comic for Studio375’s Tenth Anniversary | Codrops Building a Scroll-Driven 3D Gallery Using a Blender Camera Path with Three.js and GSAP | Codrops From Years of Client Work to a Next.js + Sanity Starter Kit | Codrops Sculpting a Digital Athlete: Capturing Stefanos Tsitsipas Beyond the Court | Codrops Building Persistent Page Transitions with WebGPU and Vanilla JavaScript | Codrops Inside Bisous: Designing an Editorial Experience for Cinematic CGI | Codrops Engineering the Web Experience Behind Shopify’s Spring ’26 Edition: Everywhere | Codrops Shaping Stories into Experience: The Work of Kevin Lam | Codrops Testing What Users Actually See with Vitest and Chromatic | Codrops Podium: Building a Website Where Running Becomes Storytelling | Codrops Designing Beyond the Surface: How DashDigital Turns Complexity into Clarity | Codrops Creative Entrepreneurship: Designing the Machine | Codrops Exploring 3D Image Rotations on Scroll | Codrops Always Building, Always Learning: Cyd Stumpel’s Journey Through the Modern Web | Codrops Designing A Better Lou: Reducing Cognitive Load Through Design, Content, and Systems | Codrops Sketching the Impossible: A 3D Portfolio Built Without a Single 3D Model | Codrops Building Horeca: Advanced Motion Design in Webflow Without the Performance Trade-Offs | Codrops Building an Interactive Digital Stamp Collection with Shaders, Postcards, and Playful Inspection | Codrops No Two Paths Alike: Inside San Rita’s Approach to Digital Experiences | Codrops The Making of the New Lesse Studio Website: Clarity, Performance, and Intentionality | Codrops Creating a Thumbnail Flow Animation with GSAP MotionPath | Codrops Forging Her Own Path: Houmahani Kane’s Journey in Creative Development | Codrops Beaucoup and the Pursuit of Memorable Experiences | Codrops The Future of Emotional Technology: Comfort Websites for Growth and Self-Awareness | Codrops The Never Ending Story: Building a Seamless Infinite Scroll Experience with GSAP & Lenis | Codrops Whooshes, Snaps and Shaders: Adrien Vanderpotte and the Feeling of the Interface | Codrops Building a Scroll-Driven 3D Cube Gallery in Webflow with GSAP | Codrops Creating Scroll-Driven SVG Map Animations with GSAP | Codrops Made With Gsap: Building a Fun Gravity-Based Mouse Trail | Codrops 80s Business Tech and Seamless Scene Transitions: Inside Shader.se’s Scroll-Driven WebGPU Pipeline | Codrops Merlin: The Code Boutique Turning Motion Into Digital Magic | Codrops Designing Ourselves: The New Obys Identity and Website | Codrops Exploring the HTML-in-Canvas Proposal | Codrops Le:mma Studio: Building the Feeling Behind the Screen | Codrops From Shader Uniforms to Clip-Path Wipes: How GSAP Drives My Portfolio | Codrops Reverse-Engineering Claude AI’s Mascot Animations with SVG and GSAP | Codrops Akaru: Crafting Precision-Driven Digital Experiences | Codrops Designing Against the Gallery: A Two-Year Journey to a Layered Portfolio Experience | Codrops Between Human and Machine: Sofia Papadopoulou’s Approach to Augmented Creativity | Codrops More Than a Portfolio: Building a Scroll-Driven 3D World with Something to Say | Codrops Rhumb Studio: Small but Mighty, Shaped by Curiosity | Codrops Susurrus: Crafting a Cozy Watercolor World with Three.js and Shaders | Codrops Building 4WIDE: Turning Distortion, Blur, and Motion into a Coherent Experience | Codrops A Playful Clip Menu with GSAP’s easeReverse | Codrops Interactive Storytelling for the Web: Building Immersive Stories with Timelines, 3D, and Layered Scenes | Codrops The Art of Complex Motion: Corentin Bernadou’s World of Shaders and Experimentation | Codrops They Call Me Giulio: The Making of a Cinematic Cyberpunk Portfolio | Codrops Lusion: Where Digital Craft Meets Ambitious Experimentation | Codrops The Exat Microsite: Pushing a Typography Showcase to New Creative Extremes | Codrops A Single DM, a First SOTD, and the Start of Chanh M. Ho’s Creative Journey | Codrops Creating Custom Page Transitions in Astro with Barba.js and GSAP | Codrops R—K ’26: The Thinking and Code Behind a Portfolio Led by Presence | Codrops Building the Maxima Therapy Website: React, GSAP, and Dabbling with AI | Codrops Where Engineering Meets Craft: Edoardo Lunardi’s Obsession with the Details | Codrops Animating 160,000 Cubes in Three.js to Visualize Dithering | Codrops Arnaud Rocca’s Portfolio: From a GSAP-Powered Motion System to Fluid WebGL | Codrops Where Confidence Needs No Decoration: The Malvah.Studio Story | Codrops Motion Highlights #17 | Codrops
False Earth: From WebGL Limits to a WebGPU-Driven World | Codrops
Ming Jyun Hu · 2026-04-21 · via Codrops

False Earth is an interactive WebGPU project and the sequel to my first work, Drift.

In Drift, an astronaut is lost in space, drifting, longing to return home. An AI-generated diary reflects his mental state — the loneliness, the depression, the memories of time spent with his family.

Now, the story continues. The astronaut has landed on a new planet. It looks like Earth — there is grass, there is sky, there is ground, but it feels strange and “false.” The grass never ends. Cosmic beams fall from the sky. Flowers bloom and die in seconds.

He made it somewhere. But this isn’t home.

The WebGL Prototypes

Before diving into False Earth, I ran two experiments to test what the browser could handle.

Vertex Animation Texture (VAT)

Rendering thousands of animated vertices was too heavy for the CPU. I moved the work to the GPU using instancing and VAT — a technique where animation data (positions and normals) is baked into a texture and replayed in the shader. In this demo, hundreds of flowers bloomed, grew, and died as the user moved the mouse.

Procedural Grass

Realistic grass is the soul of False Earth. I wanted blades that reacted to lighting and interaction, not just static geometry. Inspired by the Ghost of Tsushima team’s technical breakdown, I built a procedural system that gave me full control over every blade’s shape, color, and movement.

The WebGL Limit

But I quickly hit a wall. More flowers, more grass — and the frame rate dropped. Beyond draw calls, GPGPU in WebGL meant encoding data as pixels and juggling framebuffer object (FBO) read/write operations, which felt clunky and limited.

That led me to WebGPU. It was my first time using it along with TSL (Three.js Shading Language), and the difference was immediate. Storage buffers let me write structured data directly on the GPU and read it from any shader — no more pixel-packing workarounds. I could focus on the logic of the world rather than fighting the API.

The Infinite Grass Field

The Spatial Strategy

There is no way to generate enough individual blades to cover an entire world. Instead, I divided the field into a grid system. As the camera crosses a grid boundary, the entire vertex group snaps forward. This infinite scrolling trick keeps the grass surrounding the character no matter how far they travel. I used world position as a deterministic seed to generate parameters for each blade’s shape, color, and local elevation, which stays consistent across every grid snap.

The GPU Data Pipeline

I stored the parameters for each blade in a structured storage buffer, computed them in a compute shader, and passed the result into the rendering pipeline. Each blade’s data package is packed into four vec4 values (64 bytes per instance) for GPU-friendly alignment:

  • Position and Type: World position (xyz) and a blade type index (w) for shape variation.
  • Shape Parameters: Randomized width, height, bend curvature, and wind strength.
  • Rotation and Seeds: Pre-computed sine/cosine for facing rotation, plus clump and per-blade hash seeds for color and sway variation.
  • Compressed Normal and Interaction: Terrain normal stored as only two components (x, z)—the y component is reconstructed in the vertex shader via sqrt(1 − x² − z²), halving normal storage cost. The remaining two floats carry the character push vector.

To populate these fields with natural variation, I adopted a Voronoi clustering approach. Each blade finds its two nearest Voronoi centers and blends their parameters (height, width, bend) based on the distance to each center. This prevents hard seams at clump boundaries—blades near an edge transition gradually between their neighbors’ properties rather than snapping abruptly.

Vertex Deformation

Each blade is just a flat plane. To make it feel like organic matter, I layered multiple displacement passes in the vertex shader, each adding a different kind of motion:

Bending & Sway

Each blade follows a cubic Bézier curve with four control points. A parameter t runs from 0 at the root to 1 at the tip, and two inner control points (p1, p2) control how the blade bends:

// Cubic Bézier: 4 control points define the blade's curvature
const spine = bezier3(p0, p1, p2, p3, t);
const tangent = normalize(bezier3Tangent(p0, p1, p2, p3, t));
const side = normalize(cross(vec3(0.0, 0.0, 1.0), tangent));

Wind pushes the control points directly — mid-points shift gently, the tip more aggressively — bending the whole blade in a natural arc rather than applying a flat offset:

// Wind pushes control points proportionally (root stable, tip strongest)
const p1Pushed = p1.add(windDir.mul(windScale.mul(height).mul(0.08)));
const p2Pushed = p2.add(windDir.mul(windScale.mul(height).mul(0.15)));
const p3Pushed = p3.add(windDir.mul(windScale.mul(height).mul(0.25)));

On top of the Bézier spine I layered a dual-frequency sine-wave sway: a low-frequency oscillation for the main body and a high-frequency flutter for fine detail. Both grow stronger toward the tip, and a slow “gust” envelope modulates the amplitude so the whole field appears to breathe. For distant blades, I faded the wind intensity with a distance-based falloff to keep things stable.

// Only affects vertices near the tip
const topSwayMask = smoothstep(float(0.5), float(1.0), t);

// Gust envelope — slow breathing that modulates overall amplitude
const gust = float(0.65).add(
  float(0.35).mul(sin(uTime.mul(0.35).add(seed.mul(6.28318))))
);

// Low freq (main sway) + high freq (small flutter)
const low  = sin(uTime.mul(baseFreq).add(phase).add(t.mul(2.2)));
const high = sin(uTime.mul(baseFreq.mul(5.0)).add(phase.mul(1.7)).add(t.mul(5.0)));

// Gust drives the low-frequency sway, high-freq stays constant
const swayLow  = amp.mul(gust).mul(uWindSwayStrength);
const swayHigh = amp.mul(0.8).mul(uWindSwayStrength);

const swayAmount = low.mul(swayLow).add(high.mul(swayHigh));
return side.mul(swayAmount).mul(topSwayMask);

Terrain Alignment

Blades need to sit flush with the terrain. I computed a rotation from the local up-vector (0, 1, 0) to the terrain normal using a cross-product axis and acos(dot) angle. Since the normal is already in the storage buffer, grass, flowers, and the character all share the same elevation data.

The “Thick” Illusion

Since the blades are flat 2D planes, they can disappear when viewed edge-on. I added a view-dependent tilt that pushes vertices outward along the face normal, scaled by how edge-on the blade is to the camera. An edge mask (stronger on the sides) combined with a center mask (stronger at the base) keeps the effect from distorting the tip or adding bulk where it is not needed.

// How edge-on is this blade to the camera?
const camDirLocalY = dot(camDirW, sideW);

// Edge mask: stronger on the sides when viewed head-on
const edgeMask = uvCoords.x.sub(0.5).mul(camDirLocalY);
edgeMask.mulAssign(pow(abs(camDirLocalY), float(1.2)));

// Center mask: stronger at the base, weaker at the tip
const centerMask = pow(float(1.0).sub(t), float(0.5)).mul(pow(t.add(0.05), float(0.33)));

// Push vertices outward along the face normal
return posObj.add(normalXZ.mul(thicknessStrength.mul(edgeMask).mul(centerMask)));

Interaction

I wanted the grass to part when the character walks through it. The compute shader gives each blade an outward push vector that falls off with distance from the character, and the vertex shader weights that displacement by — strongest at the tip, zero at the root. I also flatten the blade’s height toward the ground so it doesn’t just slide sideways but compresses down as the character passes over:

// Compute shader: radial falloff from character
const pushFactor = smoothstep(pushRadius, float(0.0), charDist);
const pushVector = safeCharDir.mul(pushFactor).mul(pushAmount);

// Vertex shader: push outward + flatten height
lpos = vec3(
  lpos.x.add(pushVector.x.mul(pow(t, float(2.0)))),
  lpos.y.mul(oneMinus(pushLen.mul(flattenAmount).mul(t))),
  lpos.z.add(pushVector.y.mul(pow(t, float(2.0))))
);

In False Earth, cosmic beams fall from the sky and send energy waves rippling across the ground. Each wave’s origin, start time, radius, and lifetime live in a storage buffer, and multiple waves can overlap — the shader loops through all active entries per blade.

Each wave expands as a ring-shaped wavefront, not a filled circle. The ring width is 20% of the maximum radius, with a smoothstep falloff at the edge. A separate fade curve ramps intensity up at birth and down before death, so the ring appears, swells outward, and dissolves naturally.

The same wave strength drives both the emissive glow and the outward vertex push, so the visual ring and the physical ripple through the grass stay perfectly synchronized:

// Ring shape: distance from the expanding wavefront
const distFromWavefront = abs(dist.sub(currentRadius));
const ringWidth = maxRadius.mul(0.2);
const shape = smoothstep(ringWidth, float(0.0), distFromWavefront);

// Lifetime fade: ramp up at birth, fade out before death
const fade = smoothstep(float(1.0), float(0.5), progress)
            .mul(smoothstep(float(0.0), float(0.1), progress));

const combinedStrength = shape.mul(fade);

// Same strength drives both glow (scalar) and push (vector)
result.strength += combinedStrength;
result.force    += pushDir.mul(combinedStrength);

Procedural Shading

There are no textures on the blades — everything is computed in the shader. Here is how I achieved a convincing look without the overhead:

  • Structural Normals: A real blade has a raised midrib and thin rims at the edges. I faked this by bending the shading normal across the blade’s width using horizontal UV — a midrib inflection at the center, lifted rims near the edges — then blended it into the geometric normal at low strength so the blade catches light as if it had real cross-sectional curvature.
  • Procedural Variation: I added color randomness driven by the clump and blade seeds so no two patches look identical.
  • Height-Based AO: A simple ambient occlusion pass that darkens the roots based on blade height, adding depth for nearly zero cost.
  • Atmospheric Depth: Distant blades are gently desaturated to suggest atmospheric perspective.

VAT Flowers

Flowers in False Earth bloom wherever energy waves touch the ground. I wanted them to feel alive — appearing, growing, and dying on their own schedule — so the entire spawning logic runs in a compute shader, with each instance getting its own position, lifetime, and seed.

The Circular Spawning System

To keep spawning GPU-side without CPU readbacks, I used a circular buffer backed by a storage index.

  • Atomic Indexing: Each new flower increments a global index via atomicAdd.
  • Automatic Recycling: The index wraps with modulo against the maximum instance count, recycling slots so the lifecycle stays smooth even during heavy interaction.
// Each spawn atomically claims the next slot, wrapping to reuse old instances
const headIndex = atomicAdd(spawnStorage.get("index"), uint(1)).mod(uint(maxCount));
const instance = vatData.element(headIndex);

The Lifecycle State Machine

Once spawned, each flower moves through four phases driven by its normalized age (progress = age / lifetime). Phase boundaries (p1, p2, p3) vary per instance so they don’t all bloom in lockstep:

  • Delay [0, p1): The dormant period before growth begins. VAT frame stays at 0.
  • Grow [p1, p2): The blooming sequence where VAT progress scales from 0 to 1.
  • Keep [p2, p3): The flower holds the final animated frame (1.0) for its peak duration.
  • Die [p3, 1.0]: Progress reverses linearly from 1.0 back toward 0, so the flower withers and shrinks.
If(progress.lessThan(p1), () => {
    currentFrame.assign(0.0);
}).ElseIf(progress.lessThan(p2), () => {
    currentFrame.assign(progress.sub(p1).div(p2.sub(p1)));
}).ElseIf(progress.lessThan(p3), () => {
    currentFrame.assign(1.0);
}).Else(() => {
    const die = progress.sub(p3).div(float(1.0).sub(p3));
    currentFrame.assign(float(1.0).sub(die));
});

When the sequence finishes, the instance is marked inactive and its slot is free for the next spawn.

VAT Rendering

In the vertex shader, the VAT texture is sampled at the current frame index to reconstruct animated positions. Per-instance size variation — derived from each instance’s seed — keeps the flowers from looking stamped out.

Like the grass, flowers are aligned to terrain, affected by wind, and pushed by the character. Petals, stems, and leaves share one mesh, but I encoded a material mask into the vertex color R channel during preprocessing: 0.0 for stems, 0.5 for petals, 1.0 for leaves. In the fragment shader, a step(abs(value − threshold), 0.05) recovers each mask, letting me shade all three materials in a single draw call.

Vertex color mask debug view — Blue: Stems (R = 0.0), Red: Petals (R = 0.5), Green: Leaves (R = 1.0)

For the finishing touch, I combined a Fresnel rim glow with a traveling wave that sweeps across each petal over time. The wave offset comes from fract(time) per instance, so every flower pulses at its own rhythm — giving the flora a mystical, ethereal quality.

Optimization

The Indirect Draw Architecture

Even with everything running on the GPU, drawing millions of blades is still expensive. The first thing I wanted was frustum culling — only submitting what the camera can actually see. But since blade positions live on the GPU, CPU-side culling was out of the question. WebGPU indirect drawing solved this. The same architecture described below is reused for both the grass field and the flowers.

Indirect draw lets the GPU itself decide how many instances to draw by reading from an indirect buffer. WebGPU defines this buffer as a Uint32Array with five fields:

  • vertexCount: Number of vertices per instance.
  • instanceCount: Number of instances to draw (updated atomically).
  • firstVertex: Offset in the vertex buffer.
  • firstInstance: Offset in the instance buffer.
  • offset: Base instance offset.

In Three.js, connect the geometry to that buffer with a single call:

geometry.setIndirect(drawBuffer)

GPU-Driven Culling & Filtering

With the indirect buffer in place, a compute pass decides what gets drawn each frame. It resets instanceCount to zero, checks each blade’s visibility, and appends surviving indices into a visibleIndicesBuffer while incrementing the count atomically. Blades very close to the camera are always included so nearby grass never pops out:

// Always include blades near the camera
const isCloseEnough = abs(diff.x).add(abs(diff.z)).lessThan(float(3));
const isVisible = isCloseEnough.or(performCulling(worldPos));

If(isVisible, () => {
    const slot = atomicAdd(drawStorage.get("instanceCount"), uint(1));
    visibleIndicesBuffer.element(slot).assign(uint(instanceIndex));
});

Then, in the vertex shader, visibleIndicesBuffer is read to resolve the real instance index:

const trueIndex = visibleIndicesBuffer.element(instanceIndex);
const data = grassData.element(trueIndex);
Indirect draw flow — visibleIndicesBuffer maps compact draw slots back to the full grassDataBuffer, with instanceCount controlling how many the GPU draws

This means the vertex shader only runs on instances that are actually on screen. With blades spread around the camera, a ~75° field of view covers roughly one-fifth of the surroundings — so around 80% of instances never reach the vertex shader at all.

GPU-Driven LOD

Culling alone was not enough — I also needed level of detail (LOD). I reused the same indirect pattern, but this time with multiple buffers — one per mesh density. Three tiers:

  • High detail (15 segments): 0–5 m from camera
  • Medium detail (5 segments): 5–20 m
  • Low detail (2 segments): 20 m to horizon

The compute pass measures each blade’s distance and routes it into the right bucket. Because the far ring covers vastly more area than the near ring, most visible blades land in the lowest tier (2 segments instead of 15) — drastically cutting triangle count while keeping nearby blades crisp.

LOD debug view — Red: high detail, Green: medium, Blue: low

Without care, the LOD tiers create a visible ring artifact. I broke it up with a per-instance noise jitter on the distance test:

// Jitter the distance test to soften LOD transition rings
const noiseSeed = fract(float(instanceIndex).mul(0.12345)).mul(2.0).sub(1.0);
const noisyDist = distToCamera.add(distToCamera.mul(noiseScale).mul(noiseSeed));

If(noisyDist.greaterThanEqual(minDist).and(noisyDist.lessThan(maxDist)), () => {
    const lodIndex = atomicAdd(drawStorage.get("instanceCount"), uint(1));
    indices.element(lodIndex).assign(uint(instanceIndex));
});

Non-blocking Startup: Async Compilation

Shader compilation can freeze the browser during load. I wanted the intro animation to stay smooth, so I wrapped every heavy component (grass, flowers, character) in an AsyncCompile wrapper that uses Three.js’s compileAsync and manages a three-stage pipeline:

  • Stage 1 — Compile: All components call compileAsync in parallel so shaders build simultaneously. Each compile races against a timeout — on some mobile GPUs, compilation can stall. If the timeout fires, the component skips the wait and renders immediately, accepting a small stutter rather than a frozen loading screen.
  • Stage 2 — Queue: Once compiled, each component joins a FIFO queue and waits its turn. Only one goes at a time — rendering everything at once on the first frame would overwhelm the driver.
  • Stage 3 — Upload: The component becomes visible and renders for the first time — this is where the GPU driver transfers textures, geometry, and buffers into VRAM. Since there is no completion signal, the component holds the slot for a few frames before releasing it to the next in line.

The result: a smooth idle → compiled → uploading → done state machine per component, and the loading animation runs uninterrupted throughout.

AsyncCompile pipeline — components compile in parallel, then upload to VRAM one at a time through a FIFO queue

The Final Polish

The Post-Processing

False Earth has a first-person mode, so visitors can experience the world from the astronaut’s perspective. The post-processing chain builds up layer by layer: chromatic aberration (R, G, B sampled at slightly offset UVs for lens fringing), a vignette with a cool-blue tint to suggest a helmet visor, bloom for emissive glow, and finally tone mapping for overall exposure.

In TSL, the whole chain is wired as a node graph — color and depth come out of the scene pass as nodes, and each effect plugs in as a transform:

const scenePass = pass(scene, camera);
const colorTex = scenePass.getTextureNode('output');
const depthTex = scenePass.getViewZNode();

// Each effect transforms the node chain
let finalNode = applyAberration(colorTex);
finalNode = applyVignette(finalNode);
finalNode = finalNode.add(bloom(finalNode));

const pp = new THREE.PostProcessing(renderer);
pp.outputNode = finalNode;

I also added depth of field to blur distant geometry. A side effect: thin, emissive elements like the cosmic beams got blurred too, losing their sharp, high-energy look.

To fix this, I render beams in a separate beamScene. In post-processing, I compare the two depth buffers and composite with occlusion — beams stay sharp where they read as foreground, but still disappear behind terrain.

const depthDiff = beamDepth.sub(sceneDepth);
const beamOcclusion = smoothstep(float(0), float(10), depthDiff);
finalNode = finalNode.add(beamColor.mul(beamOcclusion));
Final composite — beams blended with depth-aware occlusion

Syncing Sound to Motion

Sound was the last piece that made the world feel tangible. Footsteps are triggered from the walk/run animation phases, so timing stays in sync at both speeds. I use five samples with ±200-cent pitch randomization to avoid obvious repetition, and volume attenuates with distance using the Web Audio API. Short-lived BufferSource nodes keep CPU overhead low even with many concurrent one-shots.

Conclusion

I started my career building interactive works for physical installations. Years ago, when I first discovered Three.js, I remember thinking: I can make something and anyone with a browser can open it. No gallery, no special hardware. Just a link. That felt huge to me.

Moving from WebGL to WebGPU felt like that same leap again. Compute shaders, indirect drawing, and storage buffers gave me the tools to render over a million grass blades, drive flower lifecycles entirely on the GPU, and keep everything interactive — all inside a browser.

For me, False Earth is one step in learning what I can build beyond the constraints I used to work within. As web graphics keep advancing, I want to push further into immersive storytelling and build digital experiences that feel more alive, responsive, and emotionally grounded.

And the astronaut — I think he still has somewhere to go.