All tutorials
advanced

Building Gargantua: The Detailed WebGPU Path

Build a procedural galaxy, moving image bubbles, and a glass lens using React Native WebGPU, TypeGPU, and a reusable layer composer.

Gargantua is a three-layer GPU effect: a procedural galaxy, image bubbles flying toward the camera, and a centered glass lens that refracts everything behind it.

procedural galaxy
  └─► moving image bubbles
       └─► glass lens sampling the completed backdrop

The interesting part is not any single shader. It is the rendering architecture that lets independent scenes share a canvas, resize correctly, clean up their own resources, and opt into backdrop sampling without breaking WebGPU's read/write rules.

This is a native Expo feature. react-native-wgpu cannot run inside Expo Go, so use a native development build.

The finished effect: stationary galaxy, tap-to-fly transition, moving image bubbles, and center refraction.

What you will build

By the end, tapping the screen will toggle forward flight. The stars stretch into hyperspace streaks, image bubbles approach through perspective, and the center lens distorts the accumulated scene.

The implementation is split into six responsibilities:

  1. Canvas lifecycle and sizing.
  2. Encoded image loading.
  3. Layer composition.
  4. Moving image sprites.
  5. Backdrop-sampling glass.
  6. Procedural galaxy rendering.

1. Prepare the native project

Install the runtime pieces that match your Expo and React Native versions:

  • react-native-wgpu
  • typegpu
  • @webgpu/types
  • unplugin-typegpu
  • @shopify/react-native-skia for decoding moving-bubble images

Add the TypeGPU transform:

babel.config.js
module.exports = (api) => {
  api.cache(true)
 
  return {
    presets: ['babel-preset-expo'],
    plugins: ['unplugin-typegpu/babel'],
  }
}

Create and launch a native development build before continuing. If TypeGPU syntax reaches Metro without this transform, shader compilation will fail before a frame can be rendered.

Checkpoint

The native development build launches with the TypeGPU Babel plugin enabled.

2. Own the canvas lifecycle once

Keep GPU device setup out of the visual scenes. A useWebGPU helper should:

  • wait for a GPU device and a non-zero canvas size;
  • configure the canvas at the device pixel ratio;
  • initialize the selected scene;
  • call render(timestamp) every animation frame;
  • present the frame;
  • forward size changes;
  • stop the loop and clean up on unmount.

The screen measures layout points and passes them to the helper:

GargantuaScreen.tsx
const [canvasSize, setCanvasSize] = useState<{
  width: number
  height: number
} | null>(null)
 
const onCanvasLayout = (event: LayoutChangeEvent) => {
  const { width, height } = event.nativeEvent.layout
 
  setCanvasSize((current) =>
    current?.width === width && current?.height === height
      ? current
      : { width, height },
  )
}
 
const canvasRef = useWebGPU(scene, [scene], canvasSize)
 
return (
  <View style={StyleSheet.absoluteFill} onLayout={onCanvasLayout}>
    <Canvas ref={canvasRef} style={StyleSheet.absoluteFill} />
  </View>
)

Avoid guessing screen dimensions. React Native layout size and the actual GPU texture size can diverge after rotation, split-screen resizing, or a device pixel-ratio change.

The WebGPU lifecycle: Screen passes size to useWebGPU, which drives scene.render then present every frame, with resize and cleanup as side paths.
The canvas lifecycle: Screen → useWebGPU → scene.render → present, with resize and cleanup as side paths.
Checkpoint

The Canvas fills a measured view, and the WebGPU helper receives a non-zero size plus resize updates.

3. Load images without creating GPU resources

React Native static image imports produce asset IDs. Resolve each ID to a URI, then load the still-encoded bytes as Skia SkData:

static require(...)
  └─► React Native asset ID
       └─► resolved URI
            └─► encoded SkData

The asset list must use static require() calls so Metro can include every file:

assets/bubbles/images.ts
export const imageArray = [
  require('./bubble-one.png'),
  require('./bubble-two.png'),
  require('./bubble-three.png'),
] as const

The loading hook returns encoded data rather than ready-made GPU textures:

hooks/useLoadImages.tsx
const { datas, loading } = useLoadImages()

This boundary matters. WebGPU resources belong to the GPUDevice that created them. Decode and upload the pixels inside the moving-bubble scene, using the same device that owns its pipeline:

SkData
  └─► decode to RGBA pixels
       └─► device.createTexture(...)
            └─► device.queue.writeTexture(...)
The image pipeline: require() resolves to an asset URI to encoded SkData outside the scene, then decodes to RGBA pixels and a device-owned GPUTexture inside the scene.
require() → asset URI → SkData crosses the boundary; RGBA pixels and the GPUTexture are created inside the scene.

Prefer transparent PNGs when the sprites should read as bubbles or cutouts. JPEG data works, but it cannot preserve transparent edges.

Checkpoint

Image loading returns encoded SkData values; no GPU texture is created outside the scene factory.

4. Define the layer contract

Every visual element is a scene factory. It creates long-lived GPU resources once, then returns the methods the composer needs:

composeLayered.ts
type LayerScene = (
  props: SceneProps,
) => LayerSceneResult | Promise<LayerSceneResult>
 
interface LayerSceneResult {
  render(
    timestamp: number,
    attachment: {
      view: GPUTextureView
      loadOp: 'clear' | 'load'
    },
    backdrop: GPUTextureView | null,
  ): void
  resize?(width: number, height: number): void
  cleanup?(): void | Promise<void>
}

The factory creates pipelines, buffers, textures, samplers, and bind groups. The returned methods divide ongoing work:

MethodWhen it runsResponsibility
renderEvery frameUpdate simulation and encode draw calls.
resizeAfter a canvas size changeRefresh sizes and size-dependent resources.
cleanupDuring teardownDestroy every resource owned by this layer.

A layer never calls context.getCurrentTexture() itself. It draws into attachment.view and respects the supplied loadOp.

The direct path

When no layer samples the background, every layer can draw directly into the swapchain:

galaxy         ── clear ──► swapchain
moving bubbles ── load  ──► swapchain

The backdrop path

The glass lens must sample the complete image behind it. WebGPU cannot sample from a texture while the same pass writes to it, so the composer switches to two offscreen textures:

galaxy         ──clear──► texture A
moving bubbles ──load───► texture A
glass lens:
  sample texture A ─────► render to texture B
texture B       ──blit──► swapchain

The composer allocates, resizes, ping-pongs, and destroys the offscreen textures. Reader layers only declare readsBackdrop: true.

Two composition paths side by side: the direct path draws every layer straight to the swapchain, while the backdrop path renders through texture A and texture B so the glass lens can sample the finished scene.
The direct swapchain path beside the two-texture backdrop path that lets the glass lens sample the completed scene.
Checkpoint

Normal layers render directly, while a backdrop reader renders into the opposite offscreen texture before the final blit.

5. Render the moving image bubbles

Create the moving layer only after encoded image data has loaded:

movingBubbleScene.ts
const movingBubbles = createBubbleScene({
  datas,
  forwardEnabledRef,
  speedFactor: 1.2,
  sizeRange: [0.25, 0.7],
  fadeRate: 2,
})

The layer decodes each image, uploads a sampled texture, and creates an alpha-blended quad pipeline. Each sprite owns rectangle and alpha uniforms. The vertex shader can construct its two triangles from vertexIndex, avoiding a separate vertex buffer.

Keep the flight model in ordinary TypeScript:

spawn near center at zFar
  └─► decrease z every frame
       └─► project x/z and y/z
            └─► grow near the camera
                 └─► respawn outside the viewport

Separating motion from shader code makes spawning, projection, sizing, alpha, and exit behavior unit-testable.

Use a stable ref to fade the layer in and out without rebuilding GPU resources:

GargantuaScreen.tsx
const [isFlying, setIsFlying] = useState(false)
const isFlyingRef = useRef(isFlying)
isFlyingRef.current = isFlying

The default depth wipe reveals distant sprites before nearby ones. If no forwardEnabledRef is supplied, the visibility target remains off.

Moving bubbles with the glass layer disabled: spawn, depth wipe, perspective growth, and respawn.
Checkpoint

Forward flight reveals image bubbles that grow through perspective and respawn after passing the camera.

6. Add the glass lens

The center lens renders a fullscreen quad and reconstructs the final output from the sampled backdrop:

centerBubbleScene.ts
const glassLens = createCenterBubbleScene({
  radiusPx: 200,
  shapeN: 2,
  invertDistortion: false,
})

Inside and around the lens, the fragment shader adds:

  • radial distortion;
  • chromatic separation near the edge;
  • a prismatic rim;
  • a specular highlight;
  • a soft shadow halo.

shapeN controls the outline:

ValueResult
1Diamond or pinched
2Circle
48Squircle
16+Nearly square

Register it after the base layers:

GargantuaScreen.tsx
const scene = composeLayered([
  { scene: galaxy },
  { scene: movingBubbles },
  { scene: glassLens, readsBackdrop: true },
])

The lens is not a standalone background. It needs content behind it and must be marked readsBackdrop: true; otherwise there is nothing to refract.

Anatomy of the glass lens with five labeled features: radial distortion, chromatic edge, prismatic rim, specular highlight, and soft shadow halo.
The glass lens, labeled: radial distortion, chromatic edge, prismatic rim, specular highlight, and soft shadow halo.
Glass variants: shapeN values 2, 6, and 16, then inverted distortion toggled on.
Checkpoint

The center lens refracts the galaxy and moving bubbles without WebGPU read/write validation errors.

7. Build the procedural galaxy

Split the galaxy into CPU orchestration and GPU shader code:

  • scene.ts owns animation state, uniforms, and the render pipeline.
  • shaders.ts owns the TypeGPU vertex and fragment functions.
  • gpuTypes.ts defines the uniform structure.
  • layouts.ts connects the uniform buffer to the shaders.

Create stable controls:

GargantuaScreen.tsx
const rotationEnabledRef = useRef(false)
const forwardEnabledRef = useRef(false)
const cameraOffsetRef = useRef({ x: 0, y: 0 })
const hyperspaceEnabledRef = useRef(true)
 
const galaxy = createStarfieldScene({
  rotationEnabledRef,
  forwardEnabledRef,
  cameraOffsetRef,
  hyperspaceEnabledRef,
})

The scene reads .current every frame, so interactions update rendering without allocating a new scene.

ControlEffect
rotationEnabledRefStarts or pauses accumulated rotation.
forwardEnabledRefSprings dots toward streaks and advances travel time.
cameraOffsetRefMoves the view direction for tilt or pointer input.
hyperspaceEnabledRefSwitches between radial streaks and disc stars.

The shader draws one fullscreen triangle and generates stars procedurally in the fragment stage. For each pixel it:

  1. constructs an aspect-corrected view ray;
  2. applies camera offset and rotation;
  3. hashes angular slices into stable star attributes;
  4. accumulates several depth layers;
  5. chooses streak or disc rendering;
  6. converts a temperature-like value into star color.

Tune density and cost in the shader:

ConstantVisual effect
SLICESAngular density in hyperspace mode.
LAYERSDepth richness and shader work per pixel.
DIMMER_EXPDistribution of bright and dim stars.
GRID_DENSITYNumber of stars in disc mode.
GRID_SHARPNESSApparent size of disc-mode stars.

Tune the launch and coast response on the CPU with the speed spring, damping, and forward-rate constants.

Two states of the galaxy: stationary dot stars at rest on the left, and radial hyperspace streaks on the right where forward flight stretches every star toward the camera.
The galaxy's defining move: forward flight stretches the stationary stars into radial hyperspace streaks.
Checkpoint

The galaxy switches between stationary stars and forward-flight streaks without rebuilding its pipeline.

8. Assemble Gargantua

The final screen creates stable controls, waits for optional image data, and memoizes the composed scene:

GargantuaScreen.tsx
import React, { useMemo, useRef, useState } from 'react'
import { LayoutChangeEvent, Pressable, StyleSheet, View } from 'react-native'
import { Canvas } from 'react-native-wgpu'
 
import { useWebGPU } from '../components/webgpu/useWebGPU'
import { createCenterBubbleScene } from '../components/gargantua/centerBubbleScene'
import { composeLayered } from '../components/gargantua/composeLayered'
import { useLoadImages } from '../components/gargantua/hooks/useLoadImages'
import { createBubbleScene } from '../components/gargantua/movingBubbleScene'
import { createStarfieldScene } from '../components/gargantua/scene'
 
export default function GargantuaScreen() {
  const [isFlying, setIsFlying] = useState(false)
  const [size, setSize] = useState<{
    width: number
    height: number
  } | null>(null)
 
  const isFlyingRef = useRef(isFlying)
  isFlyingRef.current = isFlying
 
  const rotationRef = useRef(false)
  const cameraOffsetRef = useRef({ x: 0, y: 0 })
  const hyperspaceRef = useRef(true)
 
  const { datas } = useLoadImages()
 
  const scene = useMemo(() => {
    const galaxy = createStarfieldScene({
      rotationEnabledRef: rotationRef,
      forwardEnabledRef: isFlyingRef,
      cameraOffsetRef,
      hyperspaceEnabledRef: hyperspaceRef,
    })
 
    const glass = createCenterBubbleScene({
      radiusPx: 200,
      shapeN: 2,
    })
 
    if (!datas?.length) {
      return composeLayered([
        { scene: galaxy },
        { scene: glass, readsBackdrop: true },
      ])
    }
 
    return composeLayered([
      { scene: galaxy },
      {
        scene: createBubbleScene({
          datas,
          forwardEnabledRef: isFlyingRef,
        }),
      },
      { scene: glass, readsBackdrop: true },
    ])
  }, [datas])
 
  const canvasRef = useWebGPU(scene, [scene], size)
 
  const onLayout = (event: LayoutChangeEvent) => {
    const { width, height } = event.nativeEvent.layout
    setSize({ width, height })
  }
 
  return (
    <Pressable
      style={styles.screen}
      onPress={() => setIsFlying((value) => !value)}
    >
      <View style={StyleSheet.absoluteFill} onLayout={onLayout}>
        <Canvas ref={canvasRef} style={StyleSheet.absoluteFill} />
      </View>
    </Pressable>
  )
}
 
const styles = StyleSheet.create({
  screen: {
    flex: 1,
    backgroundColor: '#000',
  },
})

The fallback composition displays the galaxy and lens while images load. Once datas changes, React disposes the old scene through the lifecycle helper and initializes the complete stack.

The complete build: initial load, both tap transitions, the full three-layer effect, and an orientation change.
Checkpoint

Tapping the screen starts and stops the complete three-layer effect, and resize or unmount correctly reaches every layer.

Troubleshooting

The screen is black

Confirm the Canvas has a measured, non-zero size and that the galaxy is the first layer. Then inspect native logs for TypeGPU shader or WebGPU validation errors.

Images do not appear

Set forwardEnabledRef.current = true, confirm datas.length > 0, and verify the asset module uses static require() calls.

The glass lens does not appear

Make sure it is not the first layer and that its entry includes readsBackdrop: true.

The effect stretches after rotation

Pass the latest layout-point size to useWebGPU; changing only the React Native style leaves the GPU canvas at its previous dimensions.

TypeGPU syntax is not transformed

Confirm unplugin-typegpu/babel is configured, then restart Metro with a cleared cache.

Resource ownership checklist

  • Create every texture, buffer, pipeline, and bind group with the scene's supplied device.
  • Destroy layer-owned resources in cleanup.
  • Rebuild size-dependent state in resize.
  • Let the composer choose the target and loadOp.
  • Never sample a texture in the same pass that writes to it.
  • Memoize composed scenes so ordinary React renders do not recreate GPU resources.
On this page