Skip to content

Underwater scene

Stopped
The complete example source and integration notes are available below.

Read as Markdown

Full-page experience · Open playground

Animate a texture-driven displacement filter over a cached scene while preserving click geometry.

Pause the water, cycle strength and speed, and click inside the orange outline.

  • Feed an animated texture into an appearance-only filter.
  • Keep scene content in an explicit Layer while waves animate.
  • Displacement samples a texture’s red and green channels without moving logical targets.
  • The same live texture feeds the filter and the map preview.
  • WebGL2 and OffscreenCanvas are required for displacement.

This is a Pibbl Canvas example. Its mount owns its runtime resources; call its controller’s dispose method when removing it. The preview starts automatically unless reduced motion is enabled; Stop disposes the mount. Preserve the source’s logical canvas size and aspect ratio. Native HTML is not captured by Canvas filters.

Host setup for this source: use a canvas with width 800 and height 540, compile with jsx: "react-jsx" and jsxImportSource: "@pibbl/core", import its default mount, and call const controller = mount(canvas) after attaching the canvas. Call controller.dispose() before removing it.

import {
pibbl,
defineTexture,
DragHandle,
Group,
Layer,
ManipulationFrame,
Rectangle,
Text,
useManipulation,
useSignal,
useTexture,
type PibblController,
type PibblManipulationBox,
} from "@pibbl/core";
import { displacement, reflectedLight } from "@pibbl/core/filters";
import { marble, waterSurface } from "@pibbl/core/textures";
const skyReflection = defineTexture({
create: () => ({}),
rasterize(_, { canvas }) {
const ctx = canvas.getContext("2d")!;
const sky = ctx.createLinearGradient(0, 0, 0, canvas.height);
sky.addColorStop(0, "#c9f5ff");
sky.addColorStop(1, "#557eb0");
ctx.fillStyle = sky;
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#f8ffff";
for (let x = 12; x < canvas.width; x += 44)
ctx.fillRect(x, 10, 26, canvas.height - 20);
},
});
function Underwater() {
const enabled = useSignal(true);
const paused = useSignal(false);
const strength = useSignal(8);
const speed = useSignal(0.4);
const rippleStrength = useSignal(1);
const clicks = useSignal(0);
const viscosity = useSignal(0);
const surface = useSignal(0);
const surfaceOpacity = useSignal(0.5);
const lava = useTexture(marble({ seed: 12, scale: 8, detail: 0.8 }), {
resolution: { width: 256, height: 128 },
palette: ["#200b08", "#591408", "#d53a0c", "#ffb323"],
});
const lightOpacity = useSignal(0.15);
const patternedLight = useSignal(false);
const obstacle = useSignal<PibblManipulationBox>({
left: 480,
top: 196,
width: 96,
height: 62,
});
const obstacleManipulation = useManipulation({
box: obstacle,
move: { bounds: { left: 32, top: 94, width: 592, height: 292 } },
});
const obstacleBox = obstacle.get();
const sky = useTexture(skyReflection({}), {
resolution: { width: 128, height: 64 },
});
const water = useTexture(
waterSurface({
seed: 7,
speed: speed.get(),
paused: paused.get(),
rippleStrength: rippleStrength.get(),
viscosity: viscosity.get(),
obstacles: [
{
x: (obstacleBox.left - 32) / 592,
y: (obstacleBox.top - 94) / 292,
width: obstacleBox.width / 592,
height: obstacleBox.height / 292,
},
],
}),
{
resolution: { width: 256, height: 128 },
},
);
const map = water.paint({ mode: "stretch" });
const buttons = [
{
label: enabled.get() ? "WATER ON" : "WATER OFF",
click: () => enabled.update((value) => !value),
},
{
label: paused.get() ? "RESUME" : "PAUSE",
click: () => paused.update((value) => !value),
},
{
label: `STRENGTH ${strength.get()}`,
click: () =>
strength.update((value) => (value === 8 ? 18 : value === 18 ? 0 : 8)),
},
{
label: `RIPPLE ${rippleStrength.get()}×`,
click: () =>
rippleStrength.update((value) =>
value === 0
? 0.5
: value === 0.5
? 1
: value === 1
? 2
: value === 2
? 4
: 0,
),
},
{
label: `SPEED ${speed.get()}`,
click: () =>
speed.update((value) => (value === 0.4 ? 1 : value === 1 ? 0 : 0.4)),
},
];
const lightButtons = [
{
label: `REFLECT ${Math.round(lightOpacity.get() * 100)}%`,
click: () =>
lightOpacity.update((value) =>
value === 0 ? 0.15 : value === 0.15 ? 0.3 : value === 0.3 ? 0.5 : 0,
),
},
{
label: patternedLight.get() ? "LIGHT: TEXTURE" : "LIGHT: TINT",
click: () => patternedLight.update((value) => !value),
},
];
lightButtons.push(
{
label: `VISCOSITY ${viscosity.get()}`,
click: () =>
viscosity.update((value) =>
value === 0 ? 0.3 : value === 0.3 ? 0.7 : value === 0.7 ? 1 : 0,
),
},
{
label: `SURFACE: ${["CLEAR", "BLUE", "AMBER", "LAVA"][surface.get()]}`,
click: () => surface.update((value) => (value + 1) % 4),
},
{
label: `SURFACE ${Math.round(surfaceOpacity.get() * 100)}%`,
click: () =>
surfaceOpacity.update((value) =>
value === 0.25 ? 0.5 : value === 0.5 ? 0.8 : value === 0.8 ? 1 : 0.25,
),
},
);
return (
<Group>
<Rectangle style={{ width: 800, height: 540, fill: "#edf3ee" }} />
<Text
style={{
left: 32,
top: 43,
font: "800 28px sans-serif",
fill: "#143d42",
}}
>
A scene beneath moving water
</Text>
<Text
style={{ left: 32, top: 70, font: "14px sans-serif", fill: "#45636b" }}
>
Click anywhere in the water to send out ripples. The scene stays in
place.
</Text>
<Group
onClick={(event) => {
if (
event.x >= 32 &&
event.x <= 624 &&
event.y >= 94 &&
event.y <= 386
) {
water.send({
type: "ripple",
x: (event.x - 32) / 592,
y: (event.y - 94) / 292,
});
}
}}
style={{
filter: enabled.get()
? [
displacement({
map,
strength,
region: { x: 32, y: 94, width: 592, height: 292 },
}),
reflectedLight({
map,
region: { x: 32, y: 94, width: 592, height: 292 },
source: patternedLight.get()
? sky.paint({ mode: "stretch" })
: "#eaffff",
opacity: lightOpacity,
}),
]
: [],
}}
>
<Layer style={{ width: 800, height: 400 }}>
<Rectangle
style={{
left: 32,
top: 94,
width: 592,
height: 292,
fill: "#103c4a",
}}
/>
{Array.from({ length: 15 }, (_, index) => (
<Rectangle
key={`vertical-${index}`}
style={{
left: 40 + index * 40,
top: 94,
width: 1,
height: 292,
fill: "#2b6570",
}}
/>
))}
{Array.from({ length: 8 }, (_, index) => (
<Rectangle
key={`horizontal-${index}`}
style={{
left: 32,
top: 100 + index * 40,
width: 592,
height: 1,
fill: "#2b6570",
}}
/>
))}
<Text
style={{
left: 70,
top: 151,
font: "800 30px sans-serif",
fill: "#b6f3e2",
}}
>
STILL SCENE / LIVE WATER
</Text>
<Rectangle
style={{
left: 230,
top: 205,
width: 190,
height: 100,
fill: "#44c8ab",
cursor: "pointer",
}}
onClick={() => clicks.update((value) => value + 1)}
/>
<Text
pointerEvents="none"
style={{
left: 325,
top: 264,
textAlign: "center",
font: "800 21px sans-serif",
fill: "#103c4a",
}}
>
CLICK ME
</Text>
{surface.get() !== 0 && (
<Rectangle
pointerEvents="none"
style={{
left: 32,
top: 94,
width: 592,
height: 292,
fill:
surface.get() === 1
? "#168ab0"
: surface.get() === 2
? "#a65b0a"
: lava.paint({ mode: "stretch" }),
opacity: surfaceOpacity.get(),
}}
/>
)}
<ManipulationFrame {...obstacleManipulation.frameProps}>
<DragHandle controller={obstacleManipulation}>
<Rectangle
style={{
width: obstacleBox.width,
height: obstacleBox.height,
fill: "#184852",
stroke: "#ffc76b",
strokeWidth: 2,
cursor: "grab",
}}
/>
</DragHandle>
</ManipulationFrame>
<Text
pointerEvents="none"
style={{
left: obstacleBox.left + obstacleBox.width / 2,
top: obstacleBox.top + obstacleBox.height / 2 + 5,
textAlign: "center",
font: "700 12px sans-serif",
fill: "#d9fbf0",
}}
>
DRAG WAKE
</Text>
<Text
style={{
left: 70,
top: 356,
font: "14px monospace",
fill: "#b6f3e2",
}}
>
An explicit Layer keeps this scene cached.
</Text>
</Layer>
</Group>
<Rectangle
pointerEvents="none"
style={{
left: 230,
top: 205,
width: 190,
height: 100,
stroke: "#ffc76b",
strokeWidth: 2,
}}
/>
<Rectangle
pointerEvents="none"
style={{
left: 648,
top: 94,
width: 120,
height: 120,
fill: water.paint({ mode: "stretch" }),
}}
/>
<Text
style={{
left: 648,
top: 237,
font: "700 14px sans-serif",
fill: "#143d42",
}}
>
LIVE RG MAP
</Text>
<Text
style={{
left: 648,
top: 265,
font: "12px sans-serif",
fill: "#45636b",
}}
>
Red: horizontal
</Text>
<Text
style={{
left: 648,
top: 285,
font: "12px sans-serif",
fill: "#45636b",
}}
>
Green: vertical
</Text>
<Text
style={{
left: 648,
top: 344,
font: "700 17px monospace",
fill: "#143d42",
}}
>{`CLICKS ${clicks.get()}`}</Text>
{buttons.map((button, index) => (
<Group key={index}>
<Rectangle
style={{
left: 32 + index * 150,
top: 408,
width: 140,
height: 36,
fill: "#143d42",
cursor: "pointer",
}}
onClick={button.click}
/>
<Text
pointerEvents="none"
style={{
left: 102 + index * 150,
top: 420,
textAlign: "center",
font: "700 13px sans-serif",
fill: "#ffffff",
}}
>
{button.label}
</Text>
</Group>
))}
{lightButtons.map((button, index) => (
<Group key={`light-${index}`}>
<Rectangle
style={{
left: 32 + index * 150,
top: 458,
width: 140,
height: 36,
fill: "#2d6872",
cursor: "pointer",
}}
onClick={button.click}
/>
<Text
pointerEvents="none"
style={{
left: 102 + index * 150,
top: 470,
textAlign: "center",
font: "700 13px sans-serif",
fill: "#ffffff",
}}
>
{button.label}
</Text>
</Group>
))}
<Text
style={{ left: 32, top: 510, font: "13px sans-serif", fill: "#45636b" }}
>
Try several clicks or drag the block. Speed zero still lets ripples
spread; pause freezes everything.
</Text>
</Group>
);
}
export default function mount(canvas: HTMLCanvasElement): PibblController {
return pibbl(canvas, <Underwater />);
}

Documentation built with @pibbl/core 0.0.2, revision 2dccb19. ALPHA — NOT FOR PRODUCTION USE.