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 backdropThe 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.
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:
- Canvas lifecycle and sizing.
- Encoded image loading.
- Layer composition.
- Moving image sprites.
- Backdrop-sampling glass.
- Procedural galaxy rendering.
1. Prepare the native project
Install the runtime pieces that match your Expo and React Native versions:
react-native-wgputypegpu@webgpu/typesunplugin-typegpu@shopify/react-native-skiafor decoding moving-bubble images
Add the TypeGPU transform:
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.
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:
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 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 SkDataThe asset list must use static require() calls so Metro can include every
file:
export const imageArray = [
require('./bubble-one.png'),
require('./bubble-two.png'),
require('./bubble-three.png'),
] as constThe loading hook returns encoded data rather than ready-made GPU textures:
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(...)Prefer transparent PNGs when the sprites should read as bubbles or cutouts. JPEG data works, but it cannot preserve transparent edges.
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:
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:
| Method | When it runs | Responsibility |
|---|---|---|
render | Every frame | Update simulation and encode draw calls. |
resize | After a canvas size change | Refresh sizes and size-dependent resources. |
cleanup | During teardown | Destroy 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 ──► swapchainThe 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──► swapchainThe composer allocates, resizes, ping-pongs, and destroys the offscreen
textures. Reader layers only declare readsBackdrop: true.
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:
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 viewportSeparating 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:
const [isFlying, setIsFlying] = useState(false)
const isFlyingRef = useRef(isFlying)
isFlyingRef.current = isFlyingThe default depth wipe reveals distant sprites before nearby ones. If no
forwardEnabledRef is supplied, the visibility target remains off.
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:
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:
| Value | Result |
|---|---|
1 | Diamond or pinched |
2 | Circle |
4–8 | Squircle |
16+ | Nearly square |
Register it after the base layers:
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.
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.tsowns animation state, uniforms, and the render pipeline.shaders.tsowns the TypeGPU vertex and fragment functions.gpuTypes.tsdefines the uniform structure.layouts.tsconnects the uniform buffer to the shaders.
Create stable controls:
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.
| Control | Effect |
|---|---|
rotationEnabledRef | Starts or pauses accumulated rotation. |
forwardEnabledRef | Springs dots toward streaks and advances travel time. |
cameraOffsetRef | Moves the view direction for tilt or pointer input. |
hyperspaceEnabledRef | Switches between radial streaks and disc stars. |
The shader draws one fullscreen triangle and generates stars procedurally in the fragment stage. For each pixel it:
- constructs an aspect-corrected view ray;
- applies camera offset and rotation;
- hashes angular slices into stable star attributes;
- accumulates several depth layers;
- chooses streak or disc rendering;
- converts a temperature-like value into star color.
Tune density and cost in the shader:
| Constant | Visual effect |
|---|---|
SLICES | Angular density in hyperspace mode. |
LAYERS | Depth richness and shader work per pixel. |
DIMMER_EXP | Distribution of bright and dim stars. |
GRID_DENSITY | Number of stars in disc mode. |
GRID_SHARPNESS | Apparent size of disc-mode stars. |
Tune the launch and coast response on the CPU with the speed spring, damping, and forward-rate constants.
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:
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.
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.