Skip to content
Back to Examples

Hierarchy and Clipping

Retain nested transforms and visibility in a sprite batch, hide React subtrees with Activity, and clip the result in SpriteGroup-local space.

import { WebGPURenderer } from 'three/webgpu'
import { DataTexture, Group, NearestFilter, RGBAFormat, Scene } from 'three'
import { createPane } from '@three-flatland/devtools'
import { PixelPerfectCamera, Sprite2D, SpriteGroup, createDevtoolsProvider } from 'three-flatland'
import { gemGradientNode } from './GemBackground'
import { GEM } from './gem'
import { initializeRenderer } from './renderStartupError'
import { configureExampleRendererColor } from './rendererColorManagement'
const palette = [0x55, 0xd6, 0xbe, 0xff, 0xb4, 0x8e, 0xff, 0xff, 0xff, 0xc8, 0x57, 0xff, 0xff, 0x73, 0xa8, 0xff]
const texture = new DataTexture(new Uint8Array(palette), 4, 1, RGBAFormat)
texture.magFilter = NearestFilter
texture.needsUpdate = true
const scene = new Scene()
const sceneWithBackground = scene as unknown as { backgroundNode: unknown }
sceneWithBackground.backgroundNode = gemGradientNode({ gem: GEM })
const camera = new PixelPerfectCamera({ viewSize: 320, viewWidth: 480 })
const viewport = new SpriteGroup({ clipRect: [-120, -80, 240, 160] })
viewport.rotation.z = -0.08
scene.add(viewport)
/** Create one retained hierarchy of batched palette sprites. */
function makeView(): Group {
const host = new Group()
for (let i = 0; i < 36; i++) {
const frameIndex = i % 4
const sprite = new Sprite2D({ texture, anchor: [0.5, 0.5] })
sprite.position.set((i % 6) * 42 - 105, Math.floor(i / 6) * 42 - 105, 0)
sprite.scale.set(34, 34, 1)
sprite.setFrame({
name: String(i),
x: frameIndex / 4,
y: 0,
width: 1 / 4,
height: 1,
sourceWidth: 1,
sourceHeight: 1,
})
host.add(sprite)
}
return host
}
const firstView = makeView()
const secondView = makeView()
secondView.rotation.z = Math.PI / 4
secondView.visible = false
viewport.add(firstView, secondView)
async function main() {
const renderer = new WebGPURenderer({ antialias: false })
configureExampleRendererColor(renderer)
renderer.setPixelRatio(Math.min(devicePixelRatio, 2))
renderer.setSize(innerWidth, innerHeight)
camera.setDrawingBufferSize(renderer.domElement.width, renderer.domElement.height)
renderer.setViewport(camera.getLogicalViewport(renderer.getPixelRatio()))
document.body.appendChild(renderer.domElement)
if (!(await initializeRenderer(renderer))) return
const paneBundle = createPane({ driver: 'manual' })
const devtools = createDevtoolsProvider({ name: 'three-hierarchy-clipping' })
let active = 0
let lastSwap = performance.now()
let raf = 0
const motionPreference = matchMedia('(prefers-reduced-motion: reduce)')
/** Animate hierarchy visibility and transforms before rendering each frame. */
function frame(now: number) {
raf = requestAnimationFrame(frame)
if (!motionPreference.matches) {
if (now - lastSwap > 2200) {
active = 1 - active
firstView.visible = active === 0
secondView.visible = active === 1
lastSwap = now
}
const view = active === 0 ? firstView : secondView
view.position.y = Math.sin(now * 0.001) * 55
}
devtools.beginFrame(now, renderer)
renderer.render(scene, camera)
devtools.endFrame(renderer)
paneBundle.update()
}
resize()
frame(performance.now())
/** Keep the orthographic camera and renderer fitted to the viewport. */
function resize() {
renderer.setSize(innerWidth, innerHeight)
camera.setDrawingBufferSize(renderer.domElement.width, renderer.domElement.height)
renderer.setViewport(camera.getLogicalViewport(renderer.getPixelRatio()))
}
addEventListener('resize', resize)
if (import.meta.hot) {
import.meta.hot.dispose(() => {
cancelAnimationFrame(raf)
removeEventListener('resize', resize)
viewport.dispose()
texture.dispose()
paneBundle.pane.dispose()
devtools.dispose()
renderer.dispose()
renderer.domElement.remove()
})
}
}
void main().catch((error: unknown) => console.error('[three-flatland] Example startup failed', error))
import { Activity, useEffect, useRef, useState } from 'react'
import { Canvas, extend, useFrame } from '@react-three/fiber/webgpu'
import { DevtoolsProvider, usePane } from '@three-flatland/devtools/react'
import { DataTexture, NearestFilter, RGBAFormat, type Group } from 'three'
import { Sprite2D, SpriteGroup, usePixelPerfectCamera } from 'three-flatland/react'
import { exampleRendererColorConfig } from './rendererColorManagement'
import { ExampleFallback } from './ExampleFallback'
import { GemBackground } from './GemBackground'
import { GEM } from './gem'
extend({ Sprite2D, SpriteGroup })
const palette = new Uint8Array([
0x55, 0xd6, 0xbe, 0xff, 0xb4, 0x8e, 0xff, 0xff, 0xff, 0xc8, 0x57, 0xff, 0xff, 0x73, 0xa8, 0xff,
])
const paletteTexture = new DataTexture(palette, 4, 1, RGBAFormat)
paletteTexture.magFilter = NearestFilter
paletteTexture.needsUpdate = true
import.meta.hot?.dispose(() => paletteTexture.dispose())
const motionPreference = matchMedia('(prefers-reduced-motion: reduce)')
/** Keep the orthographic example camera fitted to the current canvas aspect. */
function Camera() {
usePixelPerfectCamera({ viewSize: 320, viewWidth: 480 })
return null
}
/** Render and optionally animate one retained hierarchy of batched symbols. */
function Symbols({
texture,
rotated = false,
animated = true,
}: {
texture: DataTexture
rotated?: boolean
animated?: boolean
}) {
const host = useRef<Group>(null)
const elapsed = useRef(0)
useFrame((_, delta) => {
if (!animated) return
elapsed.current += delta
if (host.current) host.current.position.y = Math.sin(elapsed.current) * 55
})
return (
<group ref={host} rotation-z={rotated ? Math.PI / 4 : 0}>
{Array.from({ length: 36 }, (_, i) => (
<sprite2D
key={i}
texture={texture}
position={[(i % 6) * 42 - 105, Math.floor(i / 6) * 42 - 105, 0]}
scale={[34, 34, 1]}
frame={{
name: String(i),
x: (i % 4) / 4,
y: 0,
width: 1 / 4,
height: 1,
sourceWidth: 1,
sourceHeight: 1,
}}
/>
))}
</group>
)
}
/** Alternate two Activity-owned hierarchies inside a transformed clip group. */
function Scene() {
usePane()
const [active, setActive] = useState<0 | 1>(0)
const [reducedMotion, setReducedMotion] = useState(motionPreference.matches)
useEffect(() => {
const handleChange = (event: MediaQueryListEvent) => setReducedMotion(event.matches)
motionPreference.addEventListener('change', handleChange)
return () => motionPreference.removeEventListener('change', handleChange)
}, [])
useEffect(() => {
if (reducedMotion) return
const timer = window.setInterval(() => setActive((value) => (value === 0 ? 1 : 0)), 2200)
return () => window.clearInterval(timer)
}, [reducedMotion])
return (
<spriteGroup clipRect={[-120, -80, 240, 160]} rotation-z={-0.08}>
<Activity mode={active === 0 ? 'visible' : 'hidden'}>
<Symbols texture={paletteTexture} animated={!reducedMotion} />
</Activity>
<Activity mode={active === 1 ? 'visible' : 'hidden'}>
<Symbols texture={paletteTexture} rotated animated={!reducedMotion} />
</Activity>
</spriteGroup>
)
}
/** Mount the React hierarchy, Activity, and clipping demonstration. */
export default function App() {
return (
<Canvas
orthographic
dpr={[1, 2]}
renderer={{ ...exampleRendererColorConfig }}
frameloop="always"
camera={{ position: [0, 0, 100], near: 0.1, far: 1000 }}
fallback={<ExampleFallback />}
>
<DevtoolsProvider name="react-hierarchy-clipping" />
<GemBackground gem={GEM} />
<Camera />
<Scene />
</Canvas>
)
}

Sprites can stay below ordinary three.js groups while SpriteGroup batches their pixels. Parent transforms, parent visibility, clipping, and pointer picking all follow the same hierarchy.

const viewport = new SpriteGroup({ clipRect: [-160, -90, 320, 180] })
const card = new Group()
card.add(portrait, label, button)
viewport.add(card)
scene.add(viewport)
card.position.x = 48
card.rotation.z = 0.08

The batch mesh carries the viewport transform. Each instance stores only its transform relative to that root, so moving the viewport does not upload every unchanged sprite slot.

React 19 Activity hides its host group without unmounting the subtree. Batched sprites now follow that group directly, preserving their instances and authored visibility across every hidden/visible cycle.

<spriteGroup clipRect={[-160, -90, 320, 180]}>
<Activity mode={open ? 'visible' : 'hidden'}>
<group position={[48, 0, 0]}>
<sprite2D texture={portrait} />
<sprite2D texture={button} position={[64, -40, 0]} />
</group>
</Activity>
</spriteGroup>

Open the Three.js source above to see ordinary Group visibility, nested transforms, and a transformed clipped batch.