From 8db3bead13441a5882d8689dfc6a9f09c82375d9 Mon Sep 17 00:00:00 2001 From: Maze Winther Date: Wed, 25 Mar 2026 16:47:22 +0100 Subject: feat: masks, properties refactor, shaders, storage migrations, and more --- docs/effects-renderer.md | 56 +++++++++++++++++++++++++++++++++++++----------- 1 file changed, 43 insertions(+), 13 deletions(-) (limited to 'docs') diff --git a/docs/effects-renderer.md b/docs/effects-renderer.md index fd41282b..a26bcae6 100644 --- a/docs/effects-renderer.md +++ b/docs/effects-renderer.md @@ -28,11 +28,41 @@ renderer: { } ``` -All WebGL rendering — both the main renderer and the effect preview — goes through `applyMultiPassEffect` in `apps/web/src/services/renderer/webgl-utils.ts`. Don't add a new rendering path somewhere else; update that function if needed. +### Dynamic pass counts with `buildPasses` + +Some effects need a variable number of passes depending on their parameters (e.g. blur needs more iterations at high intensity to keep quality). For these, add a `buildPasses` function to the renderer: + +```typescript +renderer: { + type: "webgl", + passes: [ /* static fallback — used if buildPasses is absent */ ], + buildPasses: ({ effectParams, width, height }) => { + // return ResolvedEffectPass[] with pre-computed uniforms + }, +} +``` + +When `buildPasses` is present, all rendering paths use it instead of the static `passes` array. The static array is kept as a structural reference and fallback for effects that don't need dynamic pass counts. + +### Resolving passes — always use `resolveEffectPasses` + +All code that consumes effect passes should go through the helper, never access `definition.renderer.passes` directly: + +```typescript +import { resolveEffectPasses } from "@/lib/effects"; + +const passes = resolveEffectPasses({ definition, effectParams, width, height }); +``` + +This handles the `buildPasses` vs static `passes` dispatch automatically. + +### Pipeline + +Linear effect chains (blur, color grading, bloom) go through `applyMultiPassEffect` in `apps/web/src/services/renderer/webgl-utils.ts`. Non-linear GPU pipelines that need branching or multi-texture passes (like JFA for signed distance fields) get their own orchestrator in `services/renderer/` and share the WebGL context via `webgl-context.ts`. ## Writing fragment shaders -Shaders live in `apps/web/src/lib/effects/definitions/`. The shared vertex shader (`effect.vert.glsl`) maps clip space to UV coordinates — don't replace it unless you have a specific reason. +Effect-specific shaders live in `apps/web/src/lib/effects/definitions/`. General-purpose GPU algorithm shaders (like JFA) live in `apps/web/src/lib/shaders/`. Domain-specific shaders that consume a general algorithm (like the mask feather smoothstep) live with their domain (e.g. `lib/masks/shaders/`). The shared vertex shader (`effect.vert.glsl`) maps clip space to UV coordinates — don't replace it unless you have a specific reason. Available uniforms (automatically injected, no need to pass them manually): - `u_texture` — the input texture (sampler2D) @@ -40,22 +70,22 @@ Available uniforms (automatically injected, no need to pass them manually): Any additional uniforms come from the `uniforms()` function in the pass definition. -**Sampling density — the most common mistake** +**Sampling density and step scaling** -Always use a step of 1 texel when sampling neighbors. Do not scale the step size with the blur radius or intensity — it creates visible discrete artifacts (ghosting/glow look) because there are large gaps between samples that the GPU fills with linear interpolation instead of your intended curve. +A fixed kernel (e.g. ±30 samples) can only cover ±30 texels at step=1. When the target sigma grows beyond ~10, the kernel can't cover enough of the Gaussian curve and the result degrades into a box filter. -```glsl -// correct — step is always 1 texel, loop count controls radius -for (int i = -30; i <= 30; i++) { - color += texture2D(u_texture, v_texCoord + texelSize * u_direction * float(i)) * weight; -} +The fix is a `u_step` uniform that spaces samples further apart. With step=4 the same 61-sample kernel covers ±120 texels. Bilinear texture filtering smooths the gaps between samples. For very large sigma, combine step scaling with **multi-iteration stacking** (multiple H+V pass pairs via `buildPasses`) — each iteration compounds the blur, and the effective sigma = per-pass sigma × √iterations. -// wrong — stepping 6 texels at a time looks ghosty at high intensity -vec2 offset = texelSize * u_direction * u_radius; -color += texture2D(u_texture, v_texCoord + offset * 2.0) * someWeight; +Keep the step size moderate (≤4) to avoid visible banding. If you need more blur than step=4 allows in a single iteration, add iterations instead of increasing the step further. + +```glsl +// u_step scales the distance between samples +float pos = float(i) * u_step; +float weight = exp(-(pos * pos) / (2.0 * u_sigma * u_sigma)); +color += texture2D(u_texture, v_texCoord + texelSize * u_direction * pos) * weight; ``` -If you need a large radius with a fixed kernel size, increase the number of samples rather than the step. +Do **not** use large step sizes (>6) in a single pass — it creates visible banding regardless of bilinear interpolation. Use multiple iterations instead. ## Y-flip and coordinate systems -- cgit v1.3.1