Composable Svelte Graphics
State-driven 3D graphics for Composable Svelte using WebGL with Babylon.js.
UPGRADE 1 AGENT ENTRY
For an application built with the integrated Upgrade 1 companion packages, begin with the managed package reference and its executable recipe. The same reference is included in the package at node_modules/@composable-svelte/graphics/MANAGED.md; use the installed version's declarations and instructions as the API authority.
Use the shipped managed Scene/overlay recipe. Each Scene attachment gets its own adapter from createAdapter; use the overlay owner prop for its lifetime. Retirement cancels delivery immediately, while disposal of a pending asynchronous initialization waits for its result.
The standalone store and callback examples below describe standalone usage. For an owned application feature, follow the managed recipe rather than copying the standalone setup and adding ad hoc lifetime glue. Candidate qualification and npm publication are separate; verify the installed package version contains this managed surface.
PACKAGE OVERVIEW
Package: @composable-svelte/graphics
Purpose: Declarative 3D graphics over Babylon.js, driven entirely by store state.
Technology Stack:
- Babylon.js: Industry-standard 3D engine
- WebGL: Babylon's
Engine, which is what this package renders through
Renderer:
There is one renderer, and it is WebGL. This file used to describe automatic
WebGPU detection with a transparent WebGL fallback; that never happened. Both
branches of the "detection" constructed the same new Engine(canvas, …) — the
WebGPU branch's own comment said Babylon would handle it — so finding a WebGPU
adapter changed no rendering at all. It changed the label, which the store
surfaced as renderer.activeRenderer: it reported webgpu, with
supportsWebGL: false, while WebGL ran.
Real WebGPU means Babylon's WebGPUEngine and its own async initialisation.
Nobody built that, so it is recorded as a gap rather than claimed.
Core Components:
Scene- Root container, manages renderer lifecycleCamera- Viewpoint and projectionLight- Illumination (ambient, directional, point, spot)Mesh- 3D objects with geometry and materials
State Management:
graphicsReducer- Pure reducer for all graphics statecreateInitialGraphicsState()- Initial state factory- Store-driven updates sync automatically to Babylon.js
QUICK START
import { createStore } from '@composable-svelte/core';
import {
Scene,
Camera,
Light,
Mesh,
graphicsReducer,
createInitialGraphicsState
} from '@composable-svelte/graphics';
// Create graphics store
const store = createStore({
initialState: createInitialGraphicsState({
backgroundColor: '#1a1a2e'
}),
reducer: graphicsReducer,
dependencies: {}
});
// Track rotation for animation
let rotation = $state(0);
function rotateShapes() {
rotation += Math.PI / 4;
}
// Render 3D scene
<Scene {store} height="500px">
<Camera {store} position={[0, 4, 12]} lookAt={[0, 0, 0]} fov={45} />
<Light {store} type="ambient" intensity={0.4} />
<Light {store} type="directional" direction={[5, 10, 7.5]} intensity={1.2} />
<Mesh
{store}
id="rotating-box"
geometry={{ type: 'box', size: 1.5 }}
material={{ color: '#ff6b6b', metallic: 0.7, roughness: 0.3 }}
position={[0, 1.5, 0]}
rotation={[0, rotation, 0]}
/>
</Scene>
<button onclick={rotateShapes}>Rotate 45°</button>
SCENE COMPONENT
Purpose: Root container for 3D rendering. Manages Babylon.js engine lifecycle and syncs store state to the renderer.
Props:
store: Store<GraphicsState, GraphicsAction>- Graphics store (required)width: string | number- Canvas width (default: '100%')height: string | number- Canvas height (default: '600px')children: Snippet- Child components (Camera, Light, Mesh)
Behavior:
- Creates canvas element
- Initializes Babylon.js
Engine(WebGL) - Dispatches
rendererInitializedaction with capabilities - Syncs store updates to Babylon.js scene
- Cleans up engine on unmount — including when unmounted mid-initialisation
Usage:
<Scene {store} height="500px">
<!-- Children render here -->
</Scene>
State Synchronization:
Scene uses a manual subscription rather than an $effect, because the callback
drives a renderer and an effect that both reads the store and mutates the scene
loops. The diffing lives in core/scene-sync.ts so it can be tested against a
spy adapter.
It diffs by object identity, not JSON.stringify. The reducer is pure and
the store holds $state.raw, so every arm returns new objects for what changed
and the very same objects for what did not — which makes identity exact and
O(1). That matters: a running animation dispatches a tick per frame, and
stringifying every mesh at 60fps is a cost. Meshes and lights are diffed per
item by id, so one changed light does not disturb the others.
Renderer Info: Access renderer info from store:
$store.renderer.activeRenderer // 'webgl' | null
$store.renderer.isInitialized // boolean
$store.renderer.capabilities // { maxTextureSize, ... }
$store.renderer.error // string | null
CAMERA COMPONENT
Purpose: Defines the viewpoint and projection for the scene.
Props:
store: Store<GraphicsState, GraphicsAction>- Graphics store (required)type: 'perspective' | 'orthographic'- Camera type (default: 'perspective')position: [number, number, number]- Camera position (required)lookAt: [number, number, number]- Target point to look at (required)fov: number- Field of view in degrees (default: 45, perspective only)orthoSize: number- Half-height of the view in world units (default: 5, orthographic only). The half-width follows from the viewport aspect.near: number- Near clipping plane (optional)far: number- Far clipping plane (optional)
Behavior:
- Dispatches
updateCameraaction on mount - Re-dispatches when props change
- Does not render visual output (state-only component)
Usage:
<!-- Perspective camera (default) -->
<Camera
{store}
position={[0, 4, 12]}
lookAt={[0, 0, 0]}
fov={45}
/>
<!-- Orthographic camera -->
<Camera
{store}
type="orthographic"
orthoSize={8}
position={[0, 10, 0]}
lookAt={[0, 0, 0]}
/>
Common Camera Positions:
- Front view:
position={[0, 0, 10]}, lookAt={[0, 0, 0]} - Top-down:
position={[0, 10, 0]}, lookAt={[0, 0, 0]} - Isometric:
position={[5, 5, 5]}, lookAt={[0, 0, 0]} - Close-up:
position={[0, 2, 5]}, lookAt={[0, 1, 0]}
LIGHT COMPONENT
Purpose: Add illumination to the scene. Supports multiple light types.
Every light has an id. The id prop is optional — <Light> generates a
stable one per component instance via $props.id() when you omit it, so the
markup below works unchanged. Supply one when you need to address the light from
outside the component, and make it unique: a second <Light> claiming an id
that is already taken warns and renders nothing rather than fighting the first
one for it.
<Light {store} id="key" type="directional" direction={[5, 10, 7]} intensity={1.2} />
Changing id moves the light rather than orphaning it, and unmounting removes
exactly the light that component owns.
Light Types:
Ambient Light
Uniform light from all directions (no position/direction).
Props:
type: 'ambient'intensity: number- Light intensity (0-1 typical, can exceed)color: string- Light color, 6-digit hex only ('#ffffff', default)
Usage:
<Light {store} type="ambient" intensity={0.4} color="#ffffff" />
The props below are discriminated by type. Passing one that does not
belong to the type you asked for — radius on an ambient light, angle on a
point light — is a compile error, not a silent drop.
Directional Light
Parallel rays from a specific direction (like sunlight).
Props:
type: 'directional'direction: [number, number, number]- The direction the light travels in (optional; defaults to[0, 1, 0]). A directional light has no position — this prop was calledpositionuntil recently, and never was one: the adapter passed it straight into Babylon's direction argument.intensity: number- Light intensitycolor: string- Light color (optional)
Usage:
<Light {store} type="directional" direction={[5, 10, 7.5]} intensity={1.2} />
Point Light
Emits light in all directions from a point (like a light bulb).
Props:
type: 'point'position: [number, number, number]- Light position (optional; defaults to[0, 1, 0])intensity: number- Light intensityradius: number- Light radius/range (optional)color: string- Light color (optional)
Usage:
<Light {store} type="point" position={[0, 3, 0]} intensity={1.5} radius={10} />
Spot Light
Cone-shaped light (like a flashlight).
Props:
type: 'spot'position: [number, number, number]- Light position (optional; defaults to[0, 1, 0])direction: [number, number, number]- Light direction vector (optional; defaults to[0, -1, 0])angle: number- Cone half-angle in radians (optional; defaults toMath.PI / 4)intensity: number- Light intensitycolor: string- Light color (optional)
Usage:
<Light
{store}
type="spot"
position={[0, 5, 0]}
direction={[0, -1, 0]}
angle={Math.PI / 6}
intensity={2.0}
/>
Common Lighting Setups:
<!-- Three-point lighting (photography standard) -->
<Light {store} type="ambient" intensity={0.3} />
<Light {store} type="directional" direction={[5, 5, 5]} intensity={1.0} /> <!-- Key -->
<Light {store} type="directional" direction={[-3, 3, -3]} intensity={0.5} /> <!-- Fill -->
<Light {store} type="directional" direction={[0, 2, -5]} intensity={0.3} /> <!-- Back -->
<!-- Outdoor scene (sun + ambient) -->
<Light {store} type="ambient" intensity={0.4} color="#87ceeb" />
<Light {store} type="directional" direction={[10, 20, 10]} intensity={1.5} color="#fff8dc" />
<!-- Indoor scene (ambient + point lights) -->
<Light {store} type="ambient" intensity={0.2} />
<Light {store} type="point" position={[0, 3, 0]} intensity={1.0} radius={5} />
<Light {store} type="point" position={[5, 2, 5]} intensity={0.8} radius={4} />
MESH COMPONENT
Purpose: Render 3D objects with geometry and materials.
Props:
store: Store<GraphicsState, GraphicsAction>- Graphics store (required)id: string- Unique identifier (required)geometry: GeometryConfig- Geometry configuration (required)material: MaterialConfig- Material configuration (required)position: [number, number, number]- Position (required)rotation: [number, number, number]- Rotation in radians (default: [0, 0, 0])scale: [number, number, number]- Scale (default: [1, 1, 1])visible: boolean- Visibility (default: true)
Lifecycle:
onMount: DispatchesaddMeshaction- Props change: Dispatches
updateMeshaction onDestroy: DispatchesremoveMeshaction
Usage:
<Mesh
{store}
id="my-cube"
geometry={{ type: 'box', size: 1.5 }}
material={{ color: '#ff6b6b', metallic: 0.7, roughness: 0.3 }}
position={[0, 1, 0]}
rotation={[0, Math.PI / 4, 0]}
scale={[1, 1, 1]}
/>
GEOMETRY TYPES
Box
Rectangular prism.
Config:
{ type: 'box'; size: number }
Example:
<Mesh
{store}
id="cube"
geometry={{ type: 'box', size: 1.5 }}
material={{ color: '#ff6b6b' }}
position={[0, 1, 0]}
/>
Sphere
Spherical geometry.
Config:
{
type: 'sphere';
radius: number;
segments?: number; // Default: 32 (higher = smoother)
}
Example:
<Mesh
{store}
id="ball"
geometry={{ type: 'sphere', radius: 0.8, segments: 32 }}
material={{ color: '#4ecdc4', metallic: 0.8, roughness: 0.2 }}
position={[0, 1, 0]}
/>
Segments: Higher values create smoother spheres but increase draw calls.
- Low poly (16 segments): Retro/stylized look
- Medium (32 segments): Default, good balance
- High poly (64 segments): Smooth, more expensive
Cylinder
Cylindrical geometry.
Config:
{
type: 'cylinder';
height: number;
diameter: number;
}
Example:
<Mesh
{store}
id="pillar"
geometry={{ type: 'cylinder', height: 2, diameter: 1 }}
material={{ color: '#95e1d3' }}
position={[0, 1, 0]}
/>
Torus
Donut-shaped geometry.
Config:
{
type: 'torus';
diameter: number; // Outer diameter
thickness: number; // Tube thickness
segments?: number; // Default: 32
}
Example:
<Mesh
{store}
id="ring"
geometry={{ type: 'torus', diameter: 1.5, thickness: 0.3, segments: 32 }}
material={{ color: '#f38181', metallic: 0.9, roughness: 0.1 }}
position={[0, 1, 0]}
/>
Plane
Flat rectangular surface.
Config:
{
type: 'plane';
width: number;
height: number;
}
Example:
<!-- Ground plane (rotated to horizontal) -->
<Mesh
{store}
id="ground"
geometry={{ type: 'plane', width: 12, height: 12 }}
material={{ color: '#aa96da', metallic: 0.3, roughness: 0.7 }}
position={[0, 0, 0]}
rotation={[Math.PI / 2, 0, 0]}
/>
Note: Planes are initially vertical (facing Z-axis). Rotate by [Math.PI / 2, 0, 0] to make horizontal (ground).
Custom
Arbitrary geometry from raw vertex data.
{
type: 'custom';
vertices: number[]; // flat xyz triples
indices: number[]; // flat triangles, indexing into vertices
normals?: number[]; // one per vertex; computed for you when omitted
uvs?: number[]; // two per vertex
}
Example:
<!-- A single triangle in the XY plane -->
<Mesh
{store}
id="tri"
geometry={{
type: 'custom',
vertices: [0, 0, 0, 1, 0, 0, 0, 1, 0],
indices: [0, 1, 2],
uvs: [0, 0, 1, 0, 0, 1]
}}
material={{ color: '#ff6b6b' }}
position={[0, 0, 0]}
/>
Validated before it reaches the store. A mesh whose custom geometry fails
any rule below is warned about and ignored — it never enters state.meshes,
so nothing renders and no later updateMesh for that id does anything either.
| rule | why |
|---|---|
| vertices and indices both non-empty | an empty array passes every other rule — 0 is a multiple of 3, and the range check is vacuous — so an empty mesh would be admitted and draw nothing |
| vertices.length a multiple of 3 | xyz triples |
| indices.length a multiple of 3 | triangles |
| every index a whole number in 0 .. vertices.length / 3 - 1 | Babylon truncates a float index through a Uint16Array and silently draws a different triangle |
| every value in vertices, normals and uvs finite | one NaN makes computed normals NaN for all three vertices of any triangle touching it |
| normals.length === vertices.length, if given | one per vertex |
| uvs.length === vertices.length / 3 * 2, if given | two per vertex; wrong here mistextures every face without erroring |
Babylon validates none of this: bad indices produce garbage geometry or throw from inside the engine.
MATERIAL PROPERTIES
MaterialConfig Interface:
interface MaterialConfig {
color: string; // 6-digit hex, e.g. '#ff6b6b' — see below
metallic?: number; // 0-1 (default: 0 — a white, untinted highlight)
roughness?: number; // 0-1 (default: 0.5 — Babylon's own specularPower)
emissive?: string; // Emissive color (optional)
alpha?: number; // 0-1 transparency (optional)
wireframe?: boolean; // Wireframe mode (default: false)
}
Colours are 6-digit hex, and only that. '#ff6b6b' and 'ff6b6b' both
parse; 'red', 'rgb(255,0,0)' and the 3-digit '#f00' do not, and render as
white with a warning. This file used to say "Hex or CSS color", which was never
true of the parser.
Not PBR. This section used to say "Materials use Physically Based Rendering
(PBR) with metallic/roughness workflow". They do not: the adapter builds a
Babylon StandardMaterial, which has no metallic or roughness channel.
roughness was read by nothing at all until recently.
Both are mapped onto the closest levers StandardMaterial offers. They are
approximations, not physically based shading, but the values below do read the
way you expect — a high roughness looks rough:
-
metallictints the highlight, interpolatingspecularColorfrom white toward the surface colour. Dielectrics reflect white; metals reflect their own colour, which is the one real difference a specular/glossiness model can express. A floor keeps it from reaching black, so a very dark metal still has a highlight forroughnessto sharpen.How visible
metallicis depends on the surface colour: on a white or near-white surface it does nothing, because white tinted toward white is white. That is correct — a white metal and white plastic really do reflect the same colour — but it meansmetallicalone does not separate thechromepreset from white plastic.roughnessis what separates those. -
roughnesssets how tight the highlight is (specularPower) and, past the midpoint, how bright. Breadth alone is not enough — a fully rough surface at full strength reads as wet rather than matte. Below the midpoint it is at full strength and only sharpens, soroughness: 0.5lands on Babylon's untouched defaults in both channels.
Omitting both fields leaves the material looking exactly as StandardMaterial
would on its own.
An earlier version of this section said roughness now worked while metallic
still mapped straight onto specularColor as a grey — which meant
metallic: 0.0 gave black, and Babylon's default shader is
finalSpecular = specularBase * specularColor, a multiply. specularPower could
not change a single pixel. Since metallic: 0.0 is what this file teaches for
plastic, rubber, wood, stone and glass, roughness was inert for 7 of the 13
presets below, the mirror included.
Real PBR is Babylon's PBRMaterial, which would change the lighting model for
every existing mesh and needs an environment texture to look right. Recorded as
a gap rather than claimed.
Metallic (0-1)
Controls how metal-like the surface appears — specifically, how much the highlight takes on the surface's own colour. It has the most effect on a saturated or dark surface and none at all on a white one.
0.0: Non-metallic (plastic, rubber, wood, stone) — a white highlight0.5: Semi-metallic (painted metal, worn surfaces)1.0: Fully metallic (polished metal, chrome) — the highlight is the surface colour
Note that 0.0 does not mean "no highlight": a non-metal still reflects
light, and roughness is what controls how much.
Examples:
// Plastic
{ color: '#ff0000', metallic: 0.0, roughness: 0.5 }
// Painted metal
{ color: '#4ecdc4', metallic: 0.5, roughness: 0.4 }
// Polished chrome
{ color: '#ffffff', metallic: 1.0, roughness: 0.1 }
Roughness (0-1)
Controls surface smoothness/reflectivity.
0.0: Mirror-smooth (glossy, high reflections)0.5: Semi-rough (satin finish)1.0: Very rough (matte, diffuse)
Examples:
// Glass/mirror
{ color: '#ffffff', metallic: 0.0, roughness: 0.0 }
// Satin finish
{ color: '#ff6b6b', metallic: 0.3, roughness: 0.5 }
// Matte rubber
{ color: '#333333', metallic: 0.0, roughness: 1.0 }
Common Material Presets
// Polished gold
const gold = { color: '#ffd700', metallic: 1.0, roughness: 0.2 };
// Brushed aluminum
const aluminum = { color: '#c0c0c0', metallic: 1.0, roughness: 0.4 };
// Copper
const copper = { color: '#b87333', metallic: 1.0, roughness: 0.3 };
// Wood
const wood = { color: '#8b4513', metallic: 0.0, roughness: 0.8 };
// Plastic
const plastic = { color: '#ff6b6b', metallic: 0.0, roughness: 0.4 };
// Stone
const stone = { color: '#808080', metallic: 0.0, roughness: 0.9 };
// Rubber
const rubber = { color: '#1a1a1a', metallic: 0.0, roughness: 1.0 };
COMPLETE EXAMPLE
Full scene with all geometry types:
<script lang="ts">
import { createStore } from '@composable-svelte/core';
import {
Scene,
Camera,
Light,
Mesh,
graphicsReducer,
createInitialGraphicsState
} from '@composable-svelte/graphics';
// Create graphics store
const store = createStore({
initialState: createInitialGraphicsState({
backgroundColor: '#1a1a2e'
}),
reducer: graphicsReducer,
dependencies: {}
});
// Track rotation for animation
let rotation = $state(0);
function rotateShapes() {
rotation += Math.PI / 4;
}
</script>
<!-- Renderer info -->
<div>
{#if $store.renderer.isInitialized}
<span>Renderer: {$store.renderer.activeRenderer?.toUpperCase()}</span>
<span>Max Texture: {$store.renderer.capabilities.maxTextureSize}px</span>
{:else if $store.renderer.error}
<span>Error: {$store.renderer.error}</span>
{:else}
<span>Initializing...</span>
{/if}
</div>
<!-- 3D Scene -->
<Scene {store} height="500px">
<Camera {store} position={[0, 4, 12]} lookAt={[0, 0, 0]} fov={45} />
<Light {store} type="ambient" intensity={0.4} color="#ffffff" />
<Light {store} type="directional" direction={[5, 10, 7.5]} intensity={1.2} color="#ffffff" />
<!-- Row 1: Box, Sphere, Cylinder -->
<Mesh
{store}
id="box"
geometry={{ type: 'box', size: 1.5 }}
material={{ color: '#ff6b6b', metallic: 0.7, roughness: 0.3 }}
position={[-4, 1.5, 0]}
rotation={[0, rotation, 0]}
/>
<Mesh
{store}
id="sphere"
geometry={{ type: 'sphere', radius: 0.8, segments: 32 }}
material={{ color: '#4ecdc4', metallic: 0.8, roughness: 0.2 }}
position={[-1.5, 1.5, 0]}
rotation={[0, rotation, 0]}
/>
<Mesh
{store}
id="cylinder"
geometry={{ type: 'cylinder', height: 2, diameter: 1 }}
material={{ color: '#95e1d3', metallic: 0.6, roughness: 0.4 }}
position={[1, 1.5, 0]}
rotation={[0, rotation, 0]}
/>
<!-- Row 2: Torus, Plane -->
<Mesh
{store}
id="torus"
geometry={{ type: 'torus', diameter: 1.5, thickness: 0.3, segments: 32 }}
material={{ color: '#f38181', metallic: 0.9, roughness: 0.1 }}
position={[3.5, 1.5, 0]}
rotation={[0, rotation, 0]}
/>
<!-- Ground plane -->
<Mesh
{store}
id="plane"
geometry={{ type: 'plane', width: 12, height: 12 }}
material={{ color: '#aa96da', metallic: 0.3, roughness: 0.7 }}
position={[0, -0.5, 0]}
rotation={[Math.PI / 2, 0, 0]}
/>
</Scene>
<button onclick={rotateShapes}>Rotate All Shapes 45°</button>
STATE MANAGEMENT
GraphicsState Interface
interface GraphicsState {
// Identity — required. Keys this scene's animation frame loop, so two scenes
// in one store do not cancel each other's. `createInitialGraphicsState`
// generates it; pass your own for a stable id across reloads, and make it
// unique.
sceneId: string;
// Renderer
renderer: {
activeRenderer: 'webgl' | null;
isInitialized: boolean;
capabilities: {
supportsWebGL: boolean;
maxTextureSize: number;
maxVertexAttributes: number;
};
error: string | null;
};
// Scene
backgroundColor: string;
// Camera
camera: CameraConfig;
// Lights
lights: LightConfig[];
// Meshes
meshes: MeshConfig[];
// Animations
animations: AnimationState[];
// Loading
isLoading: boolean;
}
sceneId is a required field and a breaking change: state built by hand,
or hydrated from a payload serialised before it existed, arrives without one.
The reducer warns in that case rather than falling back silently, because the
fallback is a shared constant — which is exactly the cross-feature cancellation
the field prevents, and a single such scene runs perfectly.
scene: SceneNode and loadingProgress: number used to be listed here. Both
were removed: scene was built once and never read or written by anything, and
loadingProgress was set to 0 at creation and never touched again — so a
consumer reading it saw 0 forever, including after loading finished.
GraphicsAction Types
type GraphicsAction =
// Renderer
| { type: 'rendererInitialized'; renderer: 'webgl'; capabilities: RendererCapabilities }
| { type: 'rendererError'; error: string }
// Camera
| { type: 'updateCamera'; camera: Partial<CameraConfig> }
| { type: 'setCameraPosition'; position: Vector3 }
| { type: 'setCameraLookAt'; lookAt: Vector3 }
// Mesh
| { type: 'addMesh'; mesh: MeshConfig }
| { type: 'removeMesh'; id: string }
| { type: 'updateMesh'; id: string; updates: Partial<MeshConfig> }
| { type: 'setMeshPosition'; id: string; position: Vector3 }
| { type: 'setMeshRotation'; id: string; rotation: Vector3 }
| { type: 'setMeshScale'; id: string; scale: Vector3 }
| { type: 'toggleMeshVisibility'; id: string }
// Light
| { type: 'addLight'; light: LightConfig }
| { type: 'removeLight'; id: string }
| { type: 'updateLight'; id: string; light: LightConfig }
// Animation
| { type: 'startAnimation'; animation: AnimationConfig }
| { type: 'stopAnimation'; id: string }
| { type: 'tick'; time: number }
// Scene
| { type: 'setBackgroundColor'; color: string }
| { type: 'clearScene' };
Lights are addressed by id, not by index. removeLight and updateLight
took an index until recently, and LightConfig had no identity at all — so a
light could only be named by its position in the array. That is what made
removal wrong: <Light> captured state.lights.length - 1 at mount and removed
by that number, while the reducer filtered positionally, so with the default
ambient light in slot 0, unmounting three <Light> children removed index 1,
then index 2 of the already-shifted array. LightConfig.id is now required, and
<Light> supplies one via $props.id() when you do not.
updateLight takes a whole LightConfig, not a Partial: it is a
discriminated union, and a partial cannot be spread across one without losing
the discriminant.
Reducer Pattern
Graphics reducer is pure and testable:
import { graphicsReducer, createInitialGraphicsState } from '@composable-svelte/graphics';
import { TestStore } from '@composable-svelte/core/test';
const store = new TestStore({
initialState: createInitialGraphicsState(),
reducer: graphicsReducer,
dependencies: {}
});
// Add mesh
await store.send({
type: 'addMesh',
mesh: {
id: 'test-cube',
geometry: { type: 'box', size: 1 },
material: { color: '#ff0000' },
position: [0, 0, 0]
}
}, (state) => {
expect(state.meshes.length).toBe(1);
expect(state.meshes[0].id).toBe('test-cube');
});
// Update position
await store.send({
type: 'setMeshPosition',
id: 'test-cube',
position: [1, 2, 3]
}, (state) => {
expect(state.meshes[0].position).toEqual([1, 2, 3]);
});
PERFORMANCE CONSIDERATIONS
Geometry Complexity
Segments: Higher segment counts create smoother geometry but increase draw calls.
// Low poly (fast, retro look)
geometry={{ type: 'sphere', radius: 1, segments: 16 }}
// Default (good balance)
geometry={{ type: 'sphere', radius: 1, segments: 32 }}
// High poly (slow, smooth)
geometry={{ type: 'sphere', radius: 1, segments: 64 }}
Draw Calls
Each mesh = 1 draw call. Minimize meshes for better performance.
Good:
// 3 meshes = 3 draw calls
<Mesh id="obj1" ... />
<Mesh id="obj2" ... />
<Mesh id="obj3" ... />
Bad:
// 1000 meshes = 1000 draw calls (very slow!)
{#each items as item}
<Mesh id={item.id} ... />
{/each}
Solution: Use instancing for many similar objects (future feature).
Update Frequency
Scene sync diffs by object identity, per mesh and per light, keyed by id.
It used to stringify, which is what made per-frame updates expensive; identity
is O(1) and the reducer guarantees it (a pure reducer over $state.raw returns
new objects for what changed and the same objects for what did not).
Updating a mesh prop every frame is still work — it reaches the renderer, which
is the point — but it is no longer quadratic work, and a reducer arm that
returns an unchanged value now costs nothing at all. Animations are the
supported way to drive per-frame change; see startAnimation.
Good:
// Update rotation only when button clicked
let rotation = $state(0);
function rotate() { rotation += Math.PI / 4; }
<Mesh rotation={[0, rotation, 0]} ... />
Bad:
// Updates every frame (60 FPS) - expensive!
let time = $state(0);
setInterval(() => { time += 0.01; }, 16);
<Mesh rotation={[0, time, 0]} ... />
Solution: drive it through startAnimation rather than dispatching a prop
change per frame — the reducer advances one frame loop for the whole store and
skips meshes whose value has not moved. (This line used to call the animation
system a "future feature"; it has not been one for some time, and two other
places in this file say so.)
COMMON PATTERNS
Rotation Animation
let rotation = $state(0);
function rotateObject() {
rotation += Math.PI / 4; // 45 degrees
}
<Mesh rotation={[0, rotation, 0]} ... />
<button onclick={rotateObject}>Rotate 45°</button>
Camera Controls
let cameraDistance = $state(12);
function zoomIn() {
cameraDistance = Math.max(5, cameraDistance - 2);
}
function zoomOut() {
cameraDistance = Math.min(20, cameraDistance + 2);
}
<Camera {store} position={[0, 4, cameraDistance]} lookAt={[0, 0, 0]} />
<button onclick={zoomIn}>Zoom In</button>
<button onclick={zoomOut}>Zoom Out</button>
Dynamic Lighting
let lightIntensity = $state(1.0);
function adjustBrightness(delta: number) {
lightIntensity = Math.max(0, Math.min(2, lightIntensity + delta));
}
<Light {store} type="directional" direction={[5, 10, 7.5]} intensity={lightIntensity} />
<button onclick={() => adjustBrightness(0.2)}>Brighter</button>
<button onclick={() => adjustBrightness(-0.2)}>Dimmer</button>
Toggle Visibility
let showObject = $state(true);
// Option 1: Conditional rendering
{#if showObject}
<Mesh id="object" ... />
{/if}
// Option 2: Visible prop
<Mesh id="object" visible={showObject} ... />
<button onclick={() => showObject = !showObject}>
{showObject ? 'Hide' : 'Show'}
</button>
FUTURE FEATURES
These features are planned but not yet implemented:
Custom Shaders
Not modelled in the types, and the sketch that used to sit here was never
accurate: it showed a type: 'custom' discriminant that the
CustomShaderMaterial interface did not have, and that interface has been
removed — the adapter's if ('color' in material) narrow dropped it in silence
and rendered Babylon's default material instead.
MeshConfig.material is MaterialConfig alone. Per-pixel shader work today
goes through <WebGLOverlay> and its 21 presets, which is a different subject:
it shades DOM elements rather than scene meshes.
Textures
// Future API
<Mesh
id="textured"
geometry={{ type: 'box', size: 1 }}
material={{
color: '#ffffff',
albedoTexture: '/textures/wood.jpg',
normalMap: '/textures/wood_normal.jpg'
}}
position={[0, 0, 0]}
/>
A declarative animation prop on <Mesh>
Animations themselves are implemented — this section used to list them as a future feature. Drive them through the store:
store.dispatch({
type: 'startAnimation',
animation: {
id: 'spin',
targetId: 'my-cube',
property: 'rotation', // 'position' | 'rotation' | 'scale'
from: [0, 0, 0],
to: [0, Math.PI * 2, 0],
duration: 2000,
loop: true,
easing: 'linear' // 'linear' | 'easeIn' | 'easeOut' | 'easeInOut'
}
});
store.dispatch({ type: 'stopAnimation', id: 'spin' });
targetId must name a mesh that already exists; an animation naming no mesh is
rejected with a warning rather than ticking forever against nothing. Removing a
mesh stops the animations targeting it.
What is still future is expressing that as a prop:
// Future API
<Mesh
{store}
id="animated"
geometry={{ type: 'box', size: 1 }}
material={{ color: '#ff6b6b' }}
position={[0, 0, 0]}
animation={{
property: 'rotation',
from: [0, 0, 0],
to: [0, Math.PI * 2, 0],
duration: 2000,
loop: true,
easing: 'linear'
}}
/>
Post-Processing
// Future API
<Scene {store} postProcessing={{
bloom: { enabled: true, intensity: 0.5 },
ssao: { enabled: true, radius: 2 },
fxaa: true
}}>
...
</Scene>
CROSS-REFERENCES
Related Skills:
- composable-svelte-core: Store, reducer, Effect system
- composable-svelte-components: UI components that complement 3D scenes
- composable-svelte-testing: TestStore for testing graphics reducers
When to Use Each Package:
- graphics: 3D scenes, WebGL rendering
- charts: 2D data visualization (see composable-svelte-charts)
- maps: Geospatial data (see composable-svelte-maps)
- code: Code editors, syntax highlighting (see composable-svelte-code)
TROUBLESHOOTING
Scene not rendering:
- Check browser WebGL support
- Verify store is created with
graphicsReducer - Check console for renderer errors in
$store.renderer.error
Objects not visible:
- Ensure Camera is pointing at objects (
lookAtprop) - Add at least one Light (scene is dark by default)
- Check mesh
visibleprop - Verify position values (objects might be off-screen)
Poor performance:
- Reduce segment counts on spheres/toruses
- Minimize number of meshes (each mesh = 1 draw call)
- Avoid updating mesh props every frame
- Use simpler geometry (box vs sphere)
TypeScript errors:
- Ensure
@composable-svelte/graphicsis installed - Check geometry config matches type (e.g.,
boxrequiressize, notradius) - Verify Vector3 arrays are exactly 3 numbers
[x, y, z]
ADDITIONAL EXPORTS
WebGLOverlay
A full-viewport WebGL canvas that renders shader effects over ordinary DOM
elements. It is an imperative escape hatch, not a reducer-driven component:
it holds no store, dispatches no actions and imports nothing from
@composable-svelte/core. It is driven entirely through methods on a
bind:this reference. Call those from a reducer's effect if you want the
architecture around it — nothing in the overlay itself will impose it.
Its canvas is position: fixed, full-viewport, pointer-events: none and
z-index: 1000, and it resizes with the window. There is no width or height
prop and it does not sit inline in a layout.
It takes exactly one prop, options, and every field of it is optional:
<script lang="ts">
import { WebGLOverlay } from '@composable-svelte/graphics';
let overlay: WebGLOverlay | null = $state(null);
let hero: HTMLImageElement | null = $state(null);
function applyEffect(): void {
if (!overlay || !hero) return;
overlay.registerElement({
id: 'hero',
domElement: hero,
shader: 'ripple-gentle'
});
}
</script>
<WebGLOverlay bind:this={overlay} />
<img bind:this={hero} src="/hero.jpg" alt="Hero" onload={applyEffect} />
OverlayOptions:
| option | meaning |
|---|---|
| targetFPS | render-loop cap. Default 60 desktop, 30 mobile |
| maxTextureSize | downscale a source larger than this — <img>, <video> and <canvas> alike, at registration and on every re-upload. It only narrows: a value above the driver's MAX_TEXTURE_SIZE is clamped to it, and a value that is not a whole number ≥ 1 is reported and ignored. The default is not simply the driver's answer: on a device detected as mobile it is Math.min(driver, 2048), and it falls back to 2048 anywhere the driver reports nothing usable. It never refuses a source for being large — but memoryBudget refuses, and so does a source with no pixels yet |
| memoryBudget | total texture bytes before further textures are rejected. Default 200MB |
| debug | console logging |
| handleContextLoss | whether to rebuild resources after a context loss. Default true; the two callbacks below fire either way, so false means "tell me, but do not recover for me" |
| onContextLost, onContextRestored | notification hooks |
| onError | receives an OverlayError. Import it and OverlayErrorCode to narrow on error.code |
The methods, reached through bind:this:
| method | |
|---|---|
| registerElement({ id, domElement, shader, updateStrategy?, onTextureLoaded? }) | start rendering over the element. onTextureLoaded fires when the texture actually exists; failures go to onError instead |
| unregisterElement(id) | stop, releasing the texture and the compiled program |
| updateElementShader(id, shader) | recompile the element with a different effect |
| updateUniforms(id, uniforms) | change what the existing program is fed, without recompiling — this is how a shader parameter is driven over time |
| updateElement(id) | re-read the element's pixels. The trigger for the manual strategy, which is what a <canvas> gets by default — and, for an element that has no texture yet, the retry that recovers one refused at registration, whatever its strategy. An <img> infers static and needs this to recover after being registered before it decoded |
| updateElementPosition(id) | re-read the element's bounds after a CSS transform moves it |
| getElement(id), getElements() | the registrations, carrying the resolved shader, current bounds, and any OverlayError |
| getCanvas(), getContext() | the canvas and the live WebGL context, for drawing alongside |
| getCurrentFPS() | measured, not target |
| start(), stop(), isRunning() | mounting starts the loop; stop() pauses it with registrations intact |
updateStrategy is 'static' (images), 'frame' (videos) or 'manual'
(canvases) — inferred from the element type when you do not pass it. A manual
element only updates when updateElement says so.
Only <img>, <video> and <canvas> can be registered. Anything else is
refused by tag name with a console error rather than mislabelled an unloaded
image.
Shader Presets
21 built-in effects, addressed by name through the preset registry:
import {
getPreset,
hasPreset,
getAllPresetNames,
getPresetsByCategory,
getPresetMetadata,
type PresetName
} from '@composable-svelte/graphics';
const effect = getPreset('wave-flowing');
registerElement takes the name — shader: 'wave-flowing' — and resolves
it internally. getPreset returns CustomShaderEffect | undefined, so passing
its result straight through is a type error under strict; reach for it when
you want to read or clone an effect, not to register one.
| family | names |
|---|---|
| ripple | ripple-gentle, ripple-strong, ripple-pulse |
| wave | wave-gentle-horizontal, wave-strong-horizontal, wave-gentle-vertical, wave-strong-vertical, wave-flowing, wave-heat |
| pixelate | pixelate-small, pixelate-medium, pixelate-large |
| blur | blur-slight, blur-medium, blur-strong |
| glitch | glitch-subtle, glitch-medium, glitch-intense |
| zoom | zoom-breathing, zoom-pulse, zoom-intense |
Every preset constant is also exported directly (RIPPLE_GENTLE, WAVE_HEAT,
…), and each family has a factory — createRippleEffect, createWaveEffect,
createPixelateEffect, createBlurEffect, createGlitchEffect,
createZoomEffect — for parameters the fixed presets do not cover.
BabylonAdapter
Direct access to the Babylon.js engine for advanced use cases beyond the declarative API:
import { BabylonAdapter } from '@composable-svelte/graphics';
const adapter = new BabylonAdapter();