Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
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);
});
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>
);
}
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
Loading