Shader graph: Clamp is the Output's default edge, and the sheet's own mode is a constant of its own (#81)
game-maker / suite (push) Successful in 9m16s
game-maker / suite (push) Successful in 9m16s
The default of the Edge param and the address mode the shared sheet physically
carries were the same constant; changing the value would have turned every
sentinel with it and given each shader sprite a full copy of its texture.
SHEET_EDGE ('mirror', what TextureLibrary uploads) is now separate from
DEFAULT_EDGE ('clamp'). A graph whose Sample reads nothing but its own pixel
draws with the plain sheet whatever the Edge says, so the common shader costs
no second copy.
Without a shader the quad's coordinates never leave 0..1, and mirror-repeat
reads like clamp within a texel - so clamp is what a sprite shows anyway.
This commit is contained in:
1 parent
03c4323794
commit
e0c459ce92
7 files changed
+74
-20
No files matched your search
@@ -37,7 +37,7 @@ npm run build # writes dist/ and regenerates index.html; the folder is wh
|
||||
The browser suite and the three example games need a machine with a GPU:
|
||||
|
||||
```bash
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 784 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-e2e.mjs && node tools/e2e.mjs # the editor, 785 checks (GM_STEPS=20 for one step)
|
||||
node tools/seed-jumprun.mjs && node tools/smoke-jumprun.mjs
|
||||
node tools/seed-tetris.mjs && node tools/smoke-tetris.mjs
|
||||
node tools/seed-actor.mjs && node tools/smoke-actor.mjs
|
||||
|
||||
@@ -10,7 +10,9 @@ import {
|
||||
floatLiteral,
|
||||
isShaderNodeDef,
|
||||
SHADER_OUTPUT,
|
||||
SHADER_SAMPLE,
|
||||
SHADER_TIME,
|
||||
SHEET_EDGE,
|
||||
STRAIGHT_FN,
|
||||
TEXEL_ALPHA,
|
||||
TEXEL_COLOR,
|
||||
@@ -29,7 +31,10 @@ export interface CompiledShader {
|
||||
uniforms: ShaderUniform[];
|
||||
/** A Time node feeds the Output: the fragment takes `uTime`, and a sprite wearing it redraws every tick. */
|
||||
time: boolean;
|
||||
/** How the picture continues past its border – the texture variant the sprite's mesh draws with. */
|
||||
/**
|
||||
* The texture variant the sprite's mesh draws with – the Output's Edge where the graph samples
|
||||
* past the border, and the plain sheet where it cannot.
|
||||
*/
|
||||
edge: EdgeMode;
|
||||
}
|
||||
|
||||
@@ -177,5 +182,14 @@ export function compileShader(graph: Graph): ShaderBuild {
|
||||
'}',
|
||||
'',
|
||||
].join('\n');
|
||||
return { compiled: { fragment, uniforms, time, edge: edgeOf(output.params) }, problems, reason: '' };
|
||||
// The edge only shows where a Sample reads somewhere other than its own pixel. A graph that never
|
||||
// shifts a UV stays on the plain sheet, so the common shader costs no second copy of the picture.
|
||||
const shifted = graph.nodes.some(
|
||||
(n) =>
|
||||
n.type === SHADER_SAMPLE &&
|
||||
emitted.has(n.id) &&
|
||||
(edgesInto(graph, n.id, 'u').length > 0 || edgesInto(graph, n.id, 'v').length > 0),
|
||||
);
|
||||
const edge = shifted ? edgeOf(output.params) : SHEET_EDGE;
|
||||
return { compiled: { fragment, uniforms, time, edge }, problems, reason: '' };
|
||||
}
|
||||
@@ -56,16 +56,22 @@ export const STRAIGHT_FN = 'vec3 straight(vec4 c) { return c.a > 0.0 ? c.rgb / c
|
||||
|
||||
/**
|
||||
* How the picture continues past its edge when a `Sample texture` reads outside 0..1 – a setting
|
||||
* of the Output node, because the sampler state is one per draw. Mirror is what every picture has
|
||||
* anyway (`TextureLibrary`); repeat is for tiles.
|
||||
* of the Output node, because the sampler state is one per draw.
|
||||
*/
|
||||
export type EdgeMode = 'repeat' | 'mirror' | 'clamp';
|
||||
export const EDGE_PARAM = 'edge';
|
||||
export const DEFAULT_EDGE: EdgeMode = 'mirror';
|
||||
/**
|
||||
* The address mode the shared sheet carries anyway (`TextureLibrary`): within a texel it reads
|
||||
* like clamp, and no other mode can have it without a cut of its own. Asking for it costs nothing,
|
||||
* so it is what a sprite without a shader draws with.
|
||||
*/
|
||||
export const SHEET_EDGE: EdgeMode = 'mirror';
|
||||
/** Clamp: the border texel goes on, which is what a sprite without a shader shows (#81). */
|
||||
export const DEFAULT_EDGE: EdgeMode = 'clamp';
|
||||
const EDGE_OPTIONS: { value: EdgeMode; label: string }[] = [
|
||||
{ value: 'clamp', label: 'Clamp' },
|
||||
{ value: 'mirror', label: 'Mirror' },
|
||||
{ value: 'repeat', label: 'Repeat' },
|
||||
{ value: 'clamp', label: 'Clamp' },
|
||||
];
|
||||
|
||||
export function edgeOf(params: PropertyBag): EdgeMode {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Assets, CanvasSource, Texture } from 'pixi.js';
|
||||
import type { FrameRect, TextureRef } from '../core/model/texture';
|
||||
import { DEFAULT_EDGE, type EdgeMode } from '../core/shader/nodes';
|
||||
import { type EdgeMode, SHEET_EDGE } from '../core/shader/nodes';
|
||||
|
||||
const DEFAULT_BASE = '/game-maker/data/textures';
|
||||
|
||||
@@ -47,10 +47,10 @@ export class TextureLibrary {
|
||||
* shared source, so two assets naming the same file with different `smooth` fight over it and
|
||||
* the last one drawn wins; upload avoids that by reusing the existing asset for a known file.
|
||||
*/
|
||||
get(ref: TextureRef, edge: EdgeMode = DEFAULT_EDGE): Texture | null {
|
||||
get(ref: TextureRef, edge: EdgeMode = SHEET_EDGE): Texture | null {
|
||||
const sheet = this.load(ref.file);
|
||||
if (!sheet) return null;
|
||||
const rect = ref.rect ?? (edge === DEFAULT_EDGE ? null : wholeOf(sheet));
|
||||
const rect = ref.rect ?? (edge === SHEET_EDGE ? null : wholeOf(sheet));
|
||||
const texture = rect ? this.crop(ref.file, sheet, rect, edge) : sheet;
|
||||
if (!texture) return null;
|
||||
const mode = ref.smooth ? 'linear' : 'nearest';
|
||||
@@ -96,7 +96,7 @@ export class TextureLibrary {
|
||||
*/
|
||||
private crop(file: string, sheet: Texture, rect: FrameRect, edge: EdgeMode): Texture | null {
|
||||
if (rect.w <= 0 || rect.h <= 0) return null;
|
||||
const key = edge === DEFAULT_EDGE ? cropKey(file, rect) : `${cropKey(file, rect)}|${edge}`;
|
||||
const key = edge === SHEET_EDGE ? cropKey(file, rect) : `${cropKey(file, rect)}|${edge}`;
|
||||
let texture = this.crops.get(key);
|
||||
if (!texture) {
|
||||
const canvas = new OffscreenCanvas(rect.w, rect.h);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { type Container, type Graphics, Mesh, MeshGeometry, Rectangle, Texture }
|
||||
import { determinant, IDENTITY, type Mat } from '../core/model/matrix';
|
||||
import { BEHAVIOR_SYMBOL, CONTAINER_SYMBOL, placementOf, textAnchor } from '../core/properties/bounds';
|
||||
import { type ColliderShape, colliderShape, isTrigger } from '../core/properties/collider';
|
||||
import { DEFAULT_EDGE } from '../core/shader/nodes';
|
||||
import { SHEET_EDGE } from '../core/shader/nodes';
|
||||
import { Theme } from '../ui';
|
||||
import type { DrawItem } from './items';
|
||||
import { disposeShader, type ShaderSource, syncShader, wornShader } from './shaders';
|
||||
@@ -56,7 +56,7 @@ export function syncSpriteMesh(
|
||||
): void {
|
||||
// Null while the picture is still loading: the box shows its plain colour until then, and once
|
||||
// the picture is there the colour tints it.
|
||||
const edge = wornShader(item.props, shaders)?.edge ?? DEFAULT_EDGE;
|
||||
const edge = wornShader(item.props, shaders)?.edge ?? SHEET_EDGE;
|
||||
const texture = item.texture ? textures.get(item.texture, edge) : null;
|
||||
placeNode(mesh, item.m, size.w, size.h);
|
||||
mesh.texture = texture ?? Texture.WHITE;
|
||||
|
||||
@@ -36,6 +36,8 @@ import {
|
||||
import { inlineInputs } from '../../src/core/graph/NodeRegistry';
|
||||
import { compileShader, shaderFingerprint } from '../../src/core/shader/compile';
|
||||
import {
|
||||
DEFAULT_EDGE,
|
||||
EDGE_PARAM,
|
||||
SHADER_NODES,
|
||||
SHADER_NUMBER,
|
||||
SHADER_OUTPUT,
|
||||
@@ -47,6 +49,7 @@ import {
|
||||
SHADER_UV,
|
||||
ShaderOutput,
|
||||
ShaderSample,
|
||||
SHEET_EDGE,
|
||||
} from '../../src/core/shader/nodes';
|
||||
import { deserializeProject } from '../../src/core/store/serialize';
|
||||
import { paletteEntries } from '../../src/editor/graph/paletteEntries';
|
||||
@@ -414,16 +417,29 @@ describe('compileShader', () => {
|
||||
const s = shaderEditor(f, 'Idle');
|
||||
s.add(SHADER_TIME);
|
||||
const out = s.add(SHADER_OUTPUT);
|
||||
let compiled = compileShader(s.asset().graph).compiled!;
|
||||
expect(compiled).toMatchObject({ time: false, edge: 'mirror' });
|
||||
expect(compiled.fragment).not.toContain('uTime');
|
||||
const params = s.asset().graph.nodes.find((n) => n.id === out)!.params;
|
||||
params.edge = 'clamp';
|
||||
expect(compileShader(s.asset().graph).compiled!.edge).toBe('clamp');
|
||||
// A new Output carries the default, so a graph never silently changes when the default does.
|
||||
expect(params[EDGE_PARAM]).toBe('clamp');
|
||||
let compiled = compileShader(s.asset().graph).compiled!;
|
||||
expect(compiled).toMatchObject({ time: false, edge: SHEET_EDGE });
|
||||
expect(compiled.fragment).not.toContain('uTime');
|
||||
// Nothing reads past the border here, so no mode can show – and none costs a second picture.
|
||||
params[EDGE_PARAM] = 'repeat';
|
||||
expect(compileShader(s.asset().graph).compiled!.edge).toBe(SHEET_EDGE);
|
||||
const uv = s.add(SHADER_UV);
|
||||
const shift = s.add('ShaderAdd', { b: 0.5 });
|
||||
const sample = s.add(SHADER_SAMPLE);
|
||||
s.link(uv, 'u', shift, 'a');
|
||||
s.link(shift, 'result', sample, 'u');
|
||||
s.link(sample, 'color', out, 'color');
|
||||
expect(compileShader(s.asset().graph).compiled!.edge).toBe('repeat');
|
||||
// A value the dropdown never offers reads as the default rather than as a mode.
|
||||
params.edge = 'sideways';
|
||||
params[EDGE_PARAM] = 'sideways';
|
||||
compiled = compileShader(s.asset().graph).compiled!;
|
||||
expect(compiled.edge).toBe('mirror');
|
||||
expect(compiled.edge).toBe(DEFAULT_EDGE);
|
||||
// A graph from before the setting existed has no value at all and takes the default too (#81).
|
||||
delete params[EDGE_PARAM];
|
||||
expect(compileShader(s.asset().graph).compiled!.edge).toBe('clamp');
|
||||
});
|
||||
|
||||
it('writes Fract, Step and Floor as the GLSL calls, per channel for a colour', () => {
|
||||
|
||||
@@ -614,6 +614,24 @@ export async function shader(t) {
|
||||
colourDistance(mirror.left, RIGHT) < 12 && colourDistance(mirror.right, RIGHT) < 12,
|
||||
mirror,
|
||||
);
|
||||
// u + 0.5 cannot tell Clamp from Mirror: both hold green past the border. u + 0.8 puts the near
|
||||
// half on 1.6, where Mirror is already back in the far half and Clamp still holds the last texel.
|
||||
const scrolledFar = async (outParams) => {
|
||||
await sampleAt(
|
||||
[node('far', 'ShaderAdd', { b: 0.8 }, 260, 0)],
|
||||
[edge('uv', 'u', 'far', 'a'), edge('far', 'result', 'sample', 'u')],
|
||||
outParams,
|
||||
);
|
||||
await sleep(400);
|
||||
return pixel(0.3, -0.3);
|
||||
};
|
||||
const byDefault = await scrolledFar({});
|
||||
const byMirror = await scrolledFar({ edge: 'mirror' });
|
||||
check(
|
||||
'with nothing set on the Output the edge is Clamp: at u + 0.8 the border texel goes on, where Mirror shows the near half again',
|
||||
colourDistance(byDefault, RIGHT) < 12 && colourDistance(byMirror, LEFT) < 12,
|
||||
{ byDefault, byMirror },
|
||||
);
|
||||
await evaluate("__gm.showTab('shader')");
|
||||
await sleep(300);
|
||||
await shot('28-shader-scroll-graph');
|
||||
|
||||
Reference in new issue
Block a user