feat: masks, properties refactor, shaders, storage migrations, and more

This commit is contained in:
Maze Winther
2026-03-29 15:48:22 +02:00
parent 39ea298a9c
commit 8db3bead13
690 changed files with 35618 additions and 7337 deletions
+43 -13
View File
@@ -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.
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.
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
// 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;
}
// 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;
// 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