Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 61 additions & 0 deletions apps/docs-tests/visual/dot-field-stack.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { expect, test } from './fixtures';
import { waitForShader } from './helpers';

// Guards DotField's transparent stacking (MAT-93): stacked over a gradient in
// one ShaderScene, the space between dots must show the gradient beneath, not
// overwrite it. Asserts color fractions instead of a screenshot baseline so
// the check is backend-independent and needs no snapshot regeneration.
test('DotField — stacked over a gradient shows the layer beneath between dots', async ({
page,
}) => {
await page.goto('/dev/dot-field-stack-probe?visualTest=1');
await page.locator('canvas').first().waitFor();
await waitForShader(page);

const shot = await page.locator('canvas').first().screenshot();
const fractions = await page.evaluate(async (pngBase64) => {
const response = await fetch(`data:image/png;base64,${pngBase64}`);
const bitmap = await createImageBitmap(await response.blob());
const canvas = document.createElement('canvas');

canvas.width = bitmap.width;
canvas.height = bitmap.height;
const context = canvas.getContext('2d')!;

context.drawImage(bitmap, 0, 0);
const { data } = context.getImageData(0, 0, bitmap.width, bitmap.height);

let gradientPixels = 0;
let dotPixels = 0;
const totalPixels = bitmap.width * bitmap.height;

for (let i = 0; i < data.length; i += 4) {
const red = data[i] ?? 0;
const green = data[i + 1] ?? 0;
const blue = data[i + 2] ?? 0;

// White dots: all channels high. Checked first — white also satisfies
// the warm-gradient test's red threshold, but not its red-over-blue gap.
if (red > 230 && green > 230 && blue > 230) {
dotPixels += 1;
} else if (red > 140 && red > blue + 50) {
// Warm gradient: red dominates blue by a wide margin. The black page
// background (a broken stack shows it through the canvas's zero-alpha
// pixels) fails the red threshold.
gradientPixels += 1;
}
}

return { gradient: gradientPixels / totalPixels, dots: dotPixels / totalPixels };
}, shot.toString('base64'));

// Dots are ~4px in 30px cells (~1.5% coverage), so a working stack is
// nearly all gradient. A broken stack is nearly all black: gradient ~0.
expect(fractions.gradient, `fractions: ${JSON.stringify(fractions)}`).toBeGreaterThan(0.8);

// The dots themselves must still render — a DotField that vanished entirely
// would otherwise pass the gradient check. The upper bound only guards a
// canvas gone all-white; measured dot coverage sits near 0.09.
expect(fractions.dots, `fractions: ${JSON.stringify(fractions)}`).toBeGreaterThan(0.0005);
expect(fractions.dots, `fractions: ${JSON.stringify(fractions)}`).toBeLessThan(0.3);
});
Binary file modified apps/docs/public/posters/dot-field.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions apps/docs/src/app/components/dot-field/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ function DotFieldDemo() {
return (
<DemoPoster
alt="Dot field shader preview: a sparse grid of small gray dots on a dark background"
pixelSize={[2048, 1280]}
src="/posters/dot-field.png"
>
<DotFieldScene params={params}>
Expand Down
11 changes: 11 additions & 0 deletions apps/docs/src/app/dev/dot-field-stack-probe/page.dev.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
'use client';

import dynamic from 'next/dynamic';

// three/webgpu references `self` at module load and cannot SSR, so the probe
// scene is loaded client-only. Dev route: invisible to production builds.
const ProbeScene = dynamic(() => import('./probe-scene'), { ssr: false });

export default function DotFieldStackProbePage() {
return <ProbeScene />;
}
29 changes: 29 additions & 0 deletions apps/docs/src/app/dev/dot-field-stack-probe/probe-scene.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
'use client';

// Stacking probe for DotField: white dots over a bright static gradient in
// ONE ShaderScene. The space between dots must show the gradient — a
// DotField that ignores its fragment alpha overwrites the layer beneath and
// the canvas shows the page background (black) there instead. The paired
// spec (visual/dot-field-stack.spec.ts) asserts pixel-color fractions, so
// there is no screenshot baseline to regenerate.
import { ShaderScene } from '@lovo/matter-react';
import { DotField } from '@matter/registry/dot-field';
import { LinearGradient } from '@matter/registry/linear-gradient';

import { VisualTestPause } from '@/lib/visualTestHooks';

// Bright warm stops: unmistakable against both the black page background
// (what shows through where the canvas alpha is 0) and the white dots.
const BACKGROUND_STOPS = [{ color: 'oklch(0.65 0.2 30)' }, { color: 'oklch(0.8 0.16 90)' }];

export default function ProbeScene() {
return (
<div style={{ width: '100vw', height: '100vh', background: '#000' }}>
<ShaderScene>
<LinearGradient angle={90} speed={0} stops={BACKGROUND_STOPS} />
<DotField color="#FFFFFF" dotSize={4} />
<VisualTestPause />
</ShaderScene>
</div>
);
}
40 changes: 38 additions & 2 deletions apps/docs/src/components/DemoPoster.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,55 @@ import { ShaderPoster } from '@lovo/matter-react/poster';
export interface DemoPosterProps {
src: string;
alt: string;
/**
* CSS size (width, height) the poster was captured at. Set it for shaders
* that size their pattern in real pixels (e.g. DotField's 30px grid):
* those posters must render at exactly their capture size — centered and
* cropped by the demo box — because any rescale changes the pattern's
* on-screen pitch and the poster no longer matches the shader that
* replaces it. Omit for resolution-independent shaders, which cover-scale.
*/
pixelSize?: readonly [number, number];
children?: ReactNode;
}

/**
* Demo-page poster boundary: bakes in the next/image conventions every shader
* demo uses (fill, priority, viewport sizes, cover). The image renders in the
* initial HTML and drops when the enclosed ShaderScene paints its first frame.
* Pixel-locked posters (see `pixelSize`) swap cover-scaling for a centered
* crop, mirroring how a pixel-sized shader anchors its pattern to the canvas
* center at any canvas size.
*/
export function DemoPoster({ src, alt, children }: DemoPosterProps) {
export function DemoPoster({ src, alt, pixelSize, children }: DemoPosterProps) {
return (
<ShaderPoster
poster={
<Image alt={alt} fill priority sizes="100vw" src={src} style={{ objectFit: 'cover' }} />
pixelSize ? (
<div style={{ position: 'relative', width: '100%', height: '100%', overflow: 'hidden' }}>
<Image
alt={alt}
height={pixelSize[1]}
priority
src={src}
style={{
position: 'absolute',
left: '50%',
top: '50%',
transform: 'translate(-50%, -50%)',
// The capture's CSS size, not the file's pixel size: the file
// carries 2x pixels for retina sharpness. maxWidth: none opts
// out of any global img max-width that would rescale it.
width: pixelSize[0],
height: pixelSize[1],
maxWidth: 'none',
}}
width={pixelSize[0]}
/>
</div>
) : (
<Image alt={alt} fill priority sizes="100vw" src={src} style={{ objectFit: 'cover' }} />
)
}
>
{children}
Expand Down
13 changes: 9 additions & 4 deletions registry/dot-field/shader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import {
useResize,
useShaderContext,
} from '@lovo/matter-react';
import { exp, length, mix, round, sin, smoothstep, uniform, uv, vec2, vec3, vec4 } from 'three/tsl';
import { exp, length, round, sin, smoothstep, uniform, uv, vec2, vec3, vec4 } from 'three/tsl';
import { Mesh, MeshBasicNodeMaterial, PlaneGeometry, Vector2 } from 'three/webgpu';

import { parseColor } from '../utils/color';
Expand Down Expand Up @@ -144,13 +144,18 @@ function buildDotFieldMaterial(
// anti-aliasing that keeps dot edges from stair-stepping.
const antialiasWidth = 0.01;
const dotMask = smoothstep(antialiasWidth, -antialiasWidth, sdf);
const dotColor = mix(vec3(0, 0, 0), vec3(redChannel, greenChannel, blueChannel), dotMask);

const material = new MeshBasicNodeMaterial();

// Alpha carries the dot mask, so the space between dots is transparent and
// whatever rendered beneath this layer shows through.
material.colorNode = vec4(dotColor, dotMask);
// whatever rendered beneath this layer shows through. `transparent` opts
// the material into GPU alpha blending — without it three ignores fragment
// alpha and this quad would overwrite any layer stacked beneath it in the
// scene. The blend already multiplies the color by alpha, so the color
// rides at full strength here; premultiplying it by the mask as well would
// darken the anti-aliased rim twice.
material.transparent = true;
material.colorNode = vec4(vec3(redChannel, greenChannel, blueChannel), dotMask);
Comment thread
hunterbecton marked this conversation as resolved.

return material;
}
Expand Down
31 changes: 17 additions & 14 deletions scripts/build-posters.sh
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,13 @@ if [ ! -f packages/matter-cli/dist/index.js ]; then
exit 1
fi

# name:format[:background] pairs (png for the flat shaders, jpg for the busy
# ones; background is optional and only needed for shaders with a transparent
# base layer, e.g. aurora, so they flatten onto a sensible color)
# name:format[:background][:WxH] entries (png for the flat shaders, jpg for
# the busy ones; background is optional and only needed for shaders with a
# transparent base layer, e.g. aurora, so they flatten onto a sensible color;
# WxH overrides the default capture size and is only needed for pixel-sized
# shaders like dot-field, whose posters render pixel-locked in the demo — see
# DemoPoster's pixelSize prop — and so must be captured larger than any demo
# box they'll be cropped into)
#
# radial-gradient is the exception to "flat means png": PNG's row filters
# predict each pixel from its left and upper neighbours, which crushes a linear
Expand All @@ -36,29 +40,28 @@ for pair in \
"mesh-gradient:jpg" \
"wave-lines:jpg" \
"vignette:jpg" \
"dot-field:png" \
"dot-field:png:#0a0a14:2048x1280" \
"radial-gradient:jpg" \
"god-rays:jpg:#0b0f1a" \
"conic-gradient:jpg" \
"dither:jpg" \
"voronoi:jpg" \
"fractal-noise:jpg" \
"blobs:jpg"; do
name="${pair%%:*}"
rest="${pair#*:}"
format="${rest%%:*}"
if [ "$rest" != "$format" ]; then
background="${rest#*:}"
else
background=""
IFS=':' read -r name format background size <<< "$pair"
width="$WIDTH"
height="$HEIGHT"
if [ -n "${size:-}" ]; then
width="${size%x*}"
height="${size#*x}"
fi
echo "==> $name ($format)"
echo "==> $name ($format, ${width}x${height})"
args=(
--source "${COMPONENTS_DIR}/${name}/scene.tsx"
--output "${OUT_DIR}/${name}.${format}"
--format "${format}"
--width "$WIDTH"
--height "$HEIGHT"
--width "$width"
--height "$height"
)
if [ -n "$background" ]; then
args+=(--background "$background")
Expand Down
Loading