From 288e156e68b58bb6663151e756f4780c1b2a1bb5 Mon Sep 17 00:00:00 2001 From: meleki Date: Sun, 13 Sep 2026 16:18:26 +0200 Subject: [PATCH] Shaders: UV, Sample texture, Texture size, Time, math nodes, edge mode; five coordinate recipes (#78) Development: 742 checks --- Development.md | 4 ++-- Shaders.md | 59 ++++++++++++++++++++++++++++++++++++++++++++------ 2 files changed, 54 insertions(+), 9 deletions(-) diff --git a/Development.md b/Development.md index 8ec3e77..3ed2ec4 100644 --- a/Development.md +++ b/Development.md @@ -12,7 +12,7 @@ npm run check # typecheck + lint + format:check + tests, the gate CI runs # code is lost (tail's is reported), so a red run reads as green npm run build # writes dist/ and regenerates index.html ./build.sh # the same under `set -o pipefail`, for callers that pipe the output -node tools/seed-e2e.mjs && node tools/e2e.mjs # 730 editor checks, screenshots in /tmp/gm-e2e-*.png +node tools/seed-e2e.mjs && node tools/e2e.mjs # 742 editor checks, screenshots in /tmp/gm-e2e-*.png GM_STEPS=20 node tools/e2e.mjs # or 1-12, or 6,20 — a subset while working on one step ./e2e.sh start [GM_STEPS] # the same run detached, see below ./e2e.sh status # live progress, or the last run's result @@ -151,4 +151,4 @@ widths to find click points. The **render device is not in this repo** — `/dev and a 1 GB `/dev/shm` come from `container.options` in the runner's own `config.yaml`, which applies to every job on that runner. Without them chromium falls back to llvmpipe, which gets WebGL 1.0 only. Measured in the job container: `ANGLE (Intel, Vulkan 1.3.230, HD Graphics 630)`, -WebGL 2.0, and the same 728 checks as here. +WebGL 2.0, and the same 742 checks as here. diff --git a/Shaders.md b/Shaders.md index 2834cd3..3cc1fa5 100644 --- a/Shaders.md +++ b/Shaders.md @@ -1,7 +1,8 @@ A **shader** is an asset: a node graph that becomes the GLSL fragment a sprite is drawn with. Like a texture or a state machine it cannot be placed — a sprite *wears* it through its `Shader` property, and the numbers the shader declares appear as rows below that dropdown. There are no built-in -shaders; the one effect the node set is sized for is the "Adjust colors" recipe below. +shaders; the node set is sized for the recipes below: "Adjust colors", and the coordinate effects +(flip, scroll, wave, pixelate, scanlines). ## The Shader tab @@ -26,13 +27,19 @@ fed to a colour port is broadcast to all three channels. A colour cannot be fed |---|---|---| | **Texture color** | out `color` | The sprite's own pixel before the shader: its texture tinted by its colour, straight (unpremultiplied). | | **Texture alpha** | out `value` | The opacity of the sprite's own pixel: 0 transparent, 1 opaque. | -| **Output** | in `color`, `alpha` (number) | What the sprite is drawn with: the colour, clamped to 0-1, and the opacity, clamped too. Each input left unconnected stays the texture's. One per graph. | +| **UV** | out `u`, `v` | Where this pixel is on the sprite's picture, 0 to 1: `u` left to right, `v` top to bottom. | +| **Sample texture** | in `u`, `v` (number) · out `color`, `alpha` | The picture at another spot, tinted like Texture color. Unconnected, `u` and `v` are this pixel's own. Past the border the picture continues as the Output's *Edge* says. | +| **Texture size** | out `width`, `height` | The picture in pixels: of the frame, when the sprite wears one. | +| **Time** | out `value` | Seconds since the editor started; see *Coordinates and time* below. | +| **Output** | in `color`, `alpha` (number) | What the sprite is drawn with: the colour, clamped to 0-1, and the opacity, clamped too. Each input left unconnected stays the texture's. One per graph. Its *Edge* setting says how the picture continues past its border when sampled outside it: *Mirror* (the default, and what every picture has without a shader), *Repeat* for tiles, *Clamp* to stretch the border pixel. | | **Property** | out `value` | Declares a number the sprite sets: `Name` is the row below the sprite's Shader dropdown and the uniform in the fragment; `Default`, `Min`, `Max` are the row's range. A name is one word (letters, digits, `_`), unique in the graph. | | **Color** | out `color` | A constant colour, picked in the inspector. | | **Number** | out `value` | A constant number, typed on the node. Handy where the same value feeds more than one input, or where an input has nothing to type on (Luminance, the Output's alpha). | | **Add**, **Subtract**, **Multiply**, **Divide** | in `a`, `b` (any) · out `result` | Arithmetic, per channel for colours. An unconnected input is the number typed on the node (0, or 1 for the second input of Multiply and Divide). | | **Mix** | in `a`, `b` (any), `t` (number) · out `result` | Blends from `a` to `b`: `t` = 0 is `a`, 1 is `b`, beyond that it extrapolates. | | **Luminance** | in `color` · out `value` | How bright a colour is (Rec. 709 weights), as one number. | +| **Sin**, **Floor**, **Fract** | in `value` (any) · out `result` | The sine of a number in radians (one wave every 6.283); rounded down to a whole number; the part after the decimal point (2.75 becomes 0.75, a rising value becomes a sawtooth). Per channel for colours. | +| **Step** | in `edge` (number), `value` (any) · out `result` | 0 while `value` is below `edge`, 1 from `edge` on: a hard cut. Per channel for colours. | An input with nothing to type on it (the Output's two, Luminance's colour) that is left unconnected reads the texel: its colour for a colour port, its alpha for the Output's `alpha`. That is why the @@ -64,6 +71,35 @@ this graph as the asset "Adjust colors" (`tools/lib/shaders.mjs`). Measured on a flat `#4080ff` sprite: brightness 1.25 gives `#80c0ff`, contrast 0 gives `#808080`, saturation 0 gives `#7c7c7c` — all byte-exact against the formula. +## Coordinates and time + +`UV` says where the pixel is, `Sample texture` reads the picture somewhere else, and the arithmetic +in between is the effect. A `Property` in place of a constant makes the amount a sprite row a +behavior can write. + +- **Flip:** `Subtract` with 1 typed in as `a` and `UV.u` as `b`, into `Sample texture.u`. +- **Scroll:** `Add` `UV.u` and a Property *offset* (or `Time` times a speed), into + `Sample texture.u`. Set the Output's Edge to *Repeat*, so the picture comes round again; with + *Mirror* it turns back on itself, with *Clamp* the border pixel stretches. +- **Wave:** `UV.u` times 20, `Add` `Time` times 4, `Sin`, times 0.02, `Add` `UV.v`, into + `Sample texture.v`. +- **Pixelate:** `UV.u` times `Texture size.width`, `Divide` by the cell size, `Floor`, times the + cell size, `Divide` by the width, into `Sample texture.u`; the same for `v` and the height. +- **Scanlines:** `UV.v` times `Texture size.height`, `Fract`, `Step` (edge 0.5), `Multiply` with + `Texture color` into the Output's colour (or the step alone into its `alpha`). + +A sprite wearing a graph the `Time` node reaches redraws on every tick: in play, and in the scene +view as well, where nothing else moves. Time is the editor's clock, not the game's: it does not +stop for a pause and does not start over with play. A graph the Time node does not reach draws +once, like any other. + +Measured in the checks (step 28): a `Step` at 0.5 on `u` is red left of the middle and blue right +of it, on `v` red above and blue below; on a picture red on the left and green on the right, +sampling at `1 - u` swaps the halves; `u + 0.5` with Edge *Repeat* shows green on the left and, +past the border, red again on the right, with *Mirror* green on both sides; with the clock held at +0.2 s and 0.7 s a `Fract` into a `Step` gives red and then blue; released, the pixel of a +`Fract(Time)` mix changes between two reads with nothing running. + ## Wearing a shader The sprite's `Shader` row is a picker over the project's shader assets (`None` is no shader: the @@ -101,10 +137,12 @@ filter pass per sprite (up to #77) the same counts measured 56 / 54 / 48 / 34 / texture itself, so pixel art stays sharp: measured in play at a fractional camera scale (1.43 screen pixels per world unit), a *Nearest* texture keeps its hard border pixel for pixel with a neutral shader on, and brightness 0.5 darkens every one of those pixels — the shader draws there, -and it does not resample. Only sprites wear shaders; text objects do not. The -node set is deliberately small — no channel split, no texture sampling at other coordinates, no -time input; each is one node in `src/core/shader/nodes.ts` (a `ShaderNodeDef` with an `emit`) and -one row in the table above. +and it does not resample. Only sprites wear shaders; text objects do not. The node set is still +small — no channel split, no colour-typed property, no second picture; each is one node in +`src/core/shader/nodes.ts` (a `ShaderNodeDef` with an `emit`) and one row in the table above. A +picture sampled past its border shows what Edge says, never the sprite's neighbour on the sheet: a +frame is a texture of its own. What no shader can do is draw outside the sprite's rectangle: the +quad has four corners and stays a quad (#79 is the vertex-side step that would change that). ## In the code @@ -118,7 +156,14 @@ same parent: Pixi 8 lets only plain containers have children, and the node keeps Two Pixi facts the code leans on: a mesh's texture is bound only for Pixi's own shader, so `GraphShader.wear` hands it over itself; and Pixi caches whether a mesh batches and re-checks that only in a frame without structure changes, so switching a shader on or off calls `mesh.unload()` -to drop that cache — otherwise a mesh that ever drew batched keeps drawing plain, shader ignored. The rows come from +to drop that cache — otherwise a mesh that ever drew batched keeps drawing plain, shader ignored. +The fragment starts with `#version 300 es`: that line is what makes Pixi compile the program as +GLSL ES 3.00 (`GlProgram` looks for it in the fragment); without it `texture()` is only a +`#define` and `textureSize` does not exist. The Output's Edge is a second cut of the picture with +that address mode (`TextureLibrary.get(ref, edge)`), because WebGL takes the sampler state from the +texture source, not from a style the shader hands over. Time is `ShaderLibrary.now()`, held still +by `__gm.shaderTime(seconds)` for the checks; a view that drew a Time shader redraws on every +editor tick (`WorldViewport.animate`). The rows come from `shaderParamDefs(graph)` in `src/core/properties/shaders.ts`; `schemaForAsset` and `schemaForObject` (`PropertySchema.ts`) append them to a sprite's schema, which is why both take the project.