DPI scaling fixes #26

Merged
zack merged 2 commits from custom-clay into master 2026-05-06 04:17:24 +00:00
19 changed files with 627 additions and 407 deletions
+53 -30
View File
@@ -5,14 +5,27 @@ Clay UI integration.
## Current state
The renderer uses a single unified `Core_2D` (`TRIANGLELIST` pipeline) with two submission
modes dispatched by a push constant:
The renderer uses a single unified `Core_2D` (`TRIANGLELIST` pipeline) with three submission
modes dispatched by a push constant. The split is by **vertex coordinate space**, not by what the
fragment shader does — modes 0 and 2 share the same fragment-shader path (kind 0) and differ only
in whether the vertex shader applies `dpi_scale` to incoming positions:
- **Mode 0 (Tessellated):** Vertex buffer contains real geometry. Used for text (indexed draws into
SDL_ttf atlas textures), single-pixel points (`tess.pixel`), arbitrary user geometry
(`tess.triangle`, `tess.triangle_aa`, `tess.triangle_lines`, `tess.triangle_fan`,
`tess.triangle_strip`), and any raw vertex geometry submitted via `prepare_shape`. The fragment
shader premultiplies the texture sample (`t.rgb *= t.a`) and computes `out = color * t`.
- **Mode 0 (Tessellated):** Vertex buffer contains real geometry in _logical_ pixels. The vertex
shader scales by `dpi_scale` before projecting. Used for single-pixel points (`tess.pixel`),
arbitrary user geometry (`tess.triangle`, `tess.triangle_aa`, `tess.triangle_lines`,
`tess.triangle_fan`, `tess.triangle_strip`), and any raw vertex geometry submitted via
`prepare_shape`. The fragment shader premultiplies the texture sample (`t.rgb *= t.a`) and
computes `out = color * t`.
- **Mode 2 (Text):** Vertex buffer contains real geometry in _physical_ pixels. SDL_ttf's GPU text
engine lays out glyphs in physical pixels (`TTF_SetFontSizeDPI` is called with `72 * dpi_scale`),
so `prepare_text` adds an anchor offset that is itself snapped to integer physical pixels for
atlas-aligned bilinear sampling, then writes vertices straight to the buffer. The vertex shader
must NOT rescale these vertices. Same fragment-shader kind as Tessellated; same indexed draws
into SDL_ttf atlas textures; the only difference is the coordinate space of the input. Mode 2
exists because integer-physical-pixel snapping is the load-bearing property of crisp glyph
rendering and CPU is the only place that snap can happen once-per-text-element instead of
per-vertex.
- **Mode 1 (SDF):** A static 6-vertex unit-quad buffer is drawn instanced, with per-primitive
`Core_2D_Primitive` structs (96 bytes each) uploaded each frame to a GPU storage buffer. The vertex
@@ -43,8 +56,8 @@ in the pipeline plan below for the full cliff/margin analysis and SBC architectu
The fragment shader's estimated peak footprint is ~2226 fp32 VGPRs (~1622 fp16 VGPRs on architectures
with native mediump) via manual live-range analysis. The dominant peak is the Ring_Arc kind path
(wedge normals + inner/outer radii + dot-product temporaries live simultaneously with carried state
like `f_color`, `f_uv_rect`/`f_effects`, and `half_size`). RRect is 12 regs lower (`corner_radii` vec4
replaces the separate inner/outer + normal pairs). NGon and Ellipse are lighter still. Real compilers
like `f_color`, `f_uv_rect`/`f_effects`, and `half_size_ppx`). RRect is 12 regs lower
(`corner_radii_ppx` vec4 replaces the separate inner/outer + normal pairs). NGon and Ellipse are lighter still. Real compilers
apply live-range coalescing, mediump-to-fp16 promotion, and rematerialization that typically shave
24 regs from hand-counted estimates — the conservative 26-reg upper bound is expected to compile
down to within the 24-register budget, but this must be verified with `malioc` (see "Verifying
@@ -432,22 +445,32 @@ our design:
### Main pipeline: SDF + tessellated (unified)
The main pipeline serves two submission modes through a single `TRIANGLELIST` pipeline and a single
vertex input layout, distinguished by a `mode` field in the `Vertex_Uniforms_2D` push constant
(`Core_2D_Mode.Tessellated = 0`, `Core_2D_Mode.SDF = 1`), pushed per draw call via `push_globals`. The
vertex shader branches on this uniform to select the tessellated or SDF code path.
The main pipeline serves three submission modes through a single `TRIANGLELIST` pipeline and a
single vertex input layout, distinguished by a `mode` field in the `Vertex_Uniforms_2D` push
constant (`Core_2D_Mode.Tessellated = 0`, `Core_2D_Mode.SDF = 1`, `Core_2D_Mode.Text = 2`), pushed
per draw call via `push_globals`. The vertex shader branches on this uniform to select the
appropriate code path.
- **Tessellated mode** (`mode = 0`): direct vertex buffer with explicit geometry. Used for text
(SDL_ttf atlas sampling), triangles, triangle fans/strips, single-pixel points, and any
user-provided raw vertex geometry.
- **Tessellated mode** (`mode = 0`): direct vertex buffer with explicit geometry in _logical_
pixels. Vertex shader scales positions by `dpi_scale`. Used for triangles, triangle fans/strips,
single-pixel points, and any user-provided raw vertex geometry.
- **SDF mode** (`mode = 1`): shared unit-quad vertex buffer + GPU storage buffer of
`Core_2D_Primitive` structs, drawn instanced. Used for all shapes with closed-form signed distance
functions.
functions. `Core_2D_Primitive.bounds` is in logical pixels; the vertex shader scales by
`dpi_scale`.
- **Text mode** (`mode = 2`): direct vertex buffer with explicit geometry in _physical_ pixels.
Vertex shader does NOT scale. Used for SDL_ttf atlas sampling. The CPU-side anchor snap to
integer physical pixels (`prepare_text`/`prepare_text_transformed`) is what produces crisp glyphs
— sub-pixel anchors blur via the bilinear sampler. Mode 2 shares the fragment-shader path with
Tessellated (kind 0), so the only divergence between text and shape rasterization is the vertex
shader's `* dpi_scale` step.
Both modes use the same fragment shader. The fragment shader checks `Shape_Kind` (low byte of
`Core_2D_Primitive.flags`): kind 0 (`Solid`) is the tessellated path, which premultiplies the texture
sample and computes `out = color * t`; kinds 14 dispatch to one of four SDF functions (RRect, NGon,
Ellipse, Ring_Arc) and apply gradient/texture/outline/solid color based on `Shape_Flags` bits.
All three modes use the same fragment shader. Modes 0 (Tessellated) and 2 (Text) take the same
fragment-shader path (kind 0), which premultiplies the texture sample and computes `out = color * t`;
they differ only in the vertex shader (whether positions are pre-scaled to physical pixels). Mode 1
(SDF) checks `Shape_Kind` (low byte of `Core_2D_Primitive.flags`): kinds 14 dispatch to one of four
SDF functions (RRect, NGon, Ellipse, Ring_Arc) and apply gradient/texture/outline/solid color based
on `Shape_Flags` bits.
#### Why SDF for shapes
@@ -495,9 +518,9 @@ Compared to encoding per-primitive data in vertex attributes (the "fat vertex" a
buffer instancing eliminates the 46× data duplication across quad corners. A rounded rectangle costs
96 bytes instead of 4 vertices × 60+ bytes = 240+ bytes.
The tessellated path retains the existing direct vertex buffer layout (20 bytes/vertex, no storage
buffer access). The vertex shader branch on `mode` (push constant) is warp-uniform — every invocation
in a draw call has the same mode — so it is effectively free on all modern GPUs.
The tessellated and text paths retain the existing direct vertex buffer layout (20 bytes/vertex, no
storage buffer access). The vertex shader branch on `mode` (push constant) is warp-uniform — every
invocation in a draw call has the same mode — so it is effectively free on all modern GPUs.
#### Shape kinds and SDF dispatch
@@ -715,11 +738,11 @@ Clay has no notion of backdrops. The integration uses Clay's only extension poin
```
Backdrop_Marker :: struct {
magic: u32, // BACKDROP_MARKER_MAGIC (0x42445054, 'BDPT')
sigma: f32,
tint: Color,
radii: Rectangle_Radii,
feather_px: f32,
magic: u32, // BACKDROP_MARKER_MAGIC (0x42445054, 'BDPT')
sigma: f32,
tint: Color,
radii: Rectangle_Radii,
feather_ppx: f32,
}
```
@@ -762,7 +785,7 @@ Core_2D_Primitive :: struct {
flags: u32, // 20: low byte = Shape_Kind, bits 8+ = Shape_Flags
rotation_sc: u32, // 24: packed f16 pair (sin, cos). Requires .Rotated flag.
_pad: f32, // 28: reserved for future use
params: Shape_Params, // 32: per-kind params union (half_feather, radii, etc.) (32 bytes)
params: Shape_Params, // 32: per-kind params union (half_feather_ppx, radii_ppx, etc.) (32 bytes)
uv_rect: [4]f32, // 64: texture UV coordinates. Read when .Textured.
effects: Gradient_Outline, // 80: gradient and/or outline parameters (16 bytes).
}
+14 -14
View File
@@ -487,11 +487,11 @@ MAX_GAUSSIAN_BLUR_KERNEL_PAIRS :: 32
// pipeline rather than tacked onto this one as a flag bit.
//INTERNAL
Gaussian_Blur_Primitive :: struct {
bounds: [4]f32, // 0: 16 — world-space quad (min_xy, max_xy)
radii: [4]f32, // 16: 16 — per-corner radii in physical pixels (BR, TR, BL, TL)
half_size: [2]f32, // 32: 8 — RRect half extents (physical px)
half_feather: f32, // 40: 4 — feather_px * 0.5 (SDF anti-aliasing)
color: Color, // 44: 4 — tint, packed RGBA u8x4
bounds: [4]f32, // 0: 16 — world-space quad (min_xy, max_xy) in logical px
radii_ppx: [4]f32, // 16: 16 — per-corner radii (BR, TR, BL, TL)
half_size_ppx: [2]f32, // 32: 8 — RRect half extents
half_feather_ppx: f32, // 40: 4 — feather_ppx * 0.5 (SDF anti-aliasing)
color: Color, // 44: 4 — tint, packed RGBA u8x4
}
#assert(size_of(Gaussian_Blur_Primitive) == 48)
@@ -1070,7 +1070,7 @@ run_backdrop_bracket :: proc(
build_backdrop_primitive :: proc(
rect: Rectangle,
radii: Rectangle_Radii,
feather_px: f32,
feather_ppx: f32,
) -> Gaussian_Blur_Primitive {
max_radius := min(rect.width, rect.height) * 0.5
clamped_top_left := clamp(radii.top_left, 0, max_radius)
@@ -1078,8 +1078,8 @@ build_backdrop_primitive :: proc(
clamped_bottom_right := clamp(radii.bottom_right, 0, max_radius)
clamped_bottom_left := clamp(radii.bottom_left, 0, max_radius)
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
half_feather_ppx := feather_ppx * 0.5
padding := half_feather_ppx / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
half_width := rect.width * 0.5
@@ -1088,7 +1088,7 @@ build_backdrop_primitive :: proc(
center_y := rect.y + half_height
return Gaussian_Blur_Primitive {
bounds = {
bounds = {
center_x - half_width - padding,
center_y - half_height - padding,
center_x + half_width + padding,
@@ -1098,14 +1098,14 @@ build_backdrop_primitive :: proc(
// (p.x > 0) ? r.xy : r.zw picks right-vs-left half
// then (p.y > 0) ? rxy.x : rxy.y picks bottom-vs-top within that half
// So slot 0 = bottom-right, slot 1 = top-right, slot 2 = bottom-left, slot 3 = top-left.
radii = {
radii_ppx = {
clamped_bottom_right * dpi_scale,
clamped_top_right * dpi_scale,
clamped_bottom_left * dpi_scale,
clamped_top_left * dpi_scale,
},
half_size = {half_width * dpi_scale, half_height * dpi_scale},
half_feather = half_feather,
half_size_ppx = {half_width * dpi_scale, half_height * dpi_scale},
half_feather_ppx = half_feather_ppx,
}
}
@@ -1162,9 +1162,9 @@ backdrop_blur :: proc(
gaussian_sigma: f32,
tint: Color = DFT_TINT,
radii: Rectangle_Radii = {},
feather_px: f32 = DFT_FEATHER_PX,
feather_ppx: f32 = DFT_FEATHER_PPX,
) {
prim := build_backdrop_primitive(rect, radii, feather_px)
prim := build_backdrop_primitive(rect, radii, feather_ppx)
prim.color = tint
prepare_backdrop_primitive(layer, prim, gaussian_sigma)
}
+100 -81
View File
@@ -9,11 +9,8 @@ import sdl_ttf "vendor:sdl3/ttf"
//----- Vertex layout ----------------------------------
// Vertex layout for tessellated and text geometry.
// IMPORTANT: `color` must be premultiplied alpha (RGB channels pre-scaled by alpha).
// The tessellated fragment shader passes vertex color through directly — it does NOT
// premultiply. The blend state is ONE, ONE_MINUS_SRC_ALPHA (premultiplied-over).
// Use `premultiply_color` when constructing vertices manually for `prepare_shape`.
// Vertex layout for tessellated and text geometry. `color` must be premultiplied alpha; see
// the package doc's "Color and blending" section for the contract.
Vertex_2D :: struct {
position: Vec2,
uv: [2]f32,
@@ -68,35 +65,35 @@ Shape_Flags :: bit_set[Shape_Flag;u8]
//INTERNAL
RRect_Params :: struct {
half_size: [2]f32,
radii: [4]f32,
half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
_: f32,
half_size_ppx: [2]f32,
radii_ppx: [4]f32,
half_feather_ppx: f32, // feather_ppx * 0.5; shader uses smoothstep(-h, h, d)
_: f32,
}
//INTERNAL
NGon_Params :: struct {
radius: f32,
sides: f32,
half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
_: [5]f32,
radius_ppx: f32,
sides: f32,
half_feather_ppx: f32, // feather_ppx * 0.5; shader uses smoothstep(-h, h, d)
_: [5]f32,
}
//INTERNAL
Ellipse_Params :: struct {
radii: [2]f32,
half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
_: [5]f32,
radii_ppx: [2]f32,
half_feather_ppx: f32, // feather_ppx * 0.5; shader uses smoothstep(-h, h, d)
_: [5]f32,
}
//INTERNAL
Ring_Arc_Params :: struct {
inner_radius: f32, // inner radius in physical pixels (0 for pie slice)
outer_radius: f32, // outer radius in physical pixels
normal_start: [2]f32, // pre-computed outward normal of start edge: (sin(start), -cos(start))
normal_end: [2]f32, // pre-computed outward normal of end edge: (-sin(end), cos(end))
half_feather: f32, // feather_px * 0.5; shader uses smoothstep(-h, h, d)
_: f32,
inner_radius_ppx: f32, // 0 for pie slice
outer_radius_ppx: f32,
normal_start: [2]f32, // pre-computed outward normal of start edge: (sin(start), -cos(start))
normal_end: [2]f32, // pre-computed outward normal of end edge: (-sin(end), cos(end))
half_feather_ppx: f32, // feather_ppx * 0.5; shader uses smoothstep(-h, h, d)
_: f32,
}
//INTERNAL
@@ -176,9 +173,7 @@ Core_2D :: struct {
sampler: ^sdl.GPUSampler,
}
// MSAA is not supported by levlib (see init's doc comment in draw.odin); the PSO is hard-wired
// to single-sample. SDF text and shapes provide analytical AA via smoothstep; tessellated user
// geometry is not anti-aliased.
// PSO is hard-wired to single-sample (no MSAA — see package doc's "Anti-aliasing" section).
//INTERNAL
create_core_2d :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window) -> (core_2d: Core_2D, ok: bool) {
// On failure, clean up any partially-created resources
@@ -464,10 +459,31 @@ destroy_core_2d :: proc(device: ^sdl.GPUDevice, core: ^Core_2D) {
//----- Vertex uniforms ----------------------------------
//
// Coordinate-space contract for the main pipeline's vertex shader:
//
// Tessellated (0) — `v_position` arrives in *logical* pixels. The vertex
// shader multiplies by `dpi_scale` before applying the
// ortho projection (which is sized to physical pixels).
// SDF (1) — `v_position` is a unit-quad corner (0..1). World-space
// coordinates come from `Core_2D_Primitive.bounds` in
// logical pixels; the shader scales by `dpi_scale`.
// Text (2) — `v_position` arrives in *physical* pixels already.
// `prepare_text` and `prepare_text_transformed` bake the
// anchor + glyph offsets (from SDL_ttf's GPU text engine,
// which lays glyphs out in physical pixels) into the
// vertex stream and snap the anchor to integer physical
// pixels for atlas-aligned bilinear sampling. The shader
// therefore must NOT rescale these vertices.
//
// The two raw-vertex modes (Tessellated, Text) share `prepare_shape`-style
// glue but their coord spaces diverge — see `base_2d.vert` for the shader-
// side branch.
//INTERNAL
Core_2D_Mode :: enum u32 {
Tessellated = 0,
SDF = 1,
Text = 2,
}
//INTERNAL
@@ -814,9 +830,12 @@ render_layer_sub_batch_range :: proc(
sdl.DrawGPUPrimitives(render_pass, batch.count, 1, batch.offset, 0)
case .Text:
if current_mode != .Tessellated {
push_globals(cmd_buffer, width, height, .Tessellated)
current_mode = .Tessellated
// Text vertices live in physical-pixel space (see Core_2D_Mode.Text
// docs); mode 2 makes the shader skip the `* dpi_scale` step that
// the Tessellated path applies to logical-pixel input.
if current_mode != .Text {
push_globals(cmd_buffer, width, height, .Text)
current_mode = .Text
}
if current_vert_buf != main_vert_buf {
sdl.BindGPUVertexBuffers(render_pass, 0, &sdl.GPUBufferBinding{buffer = main_vert_buf, offset = 0}, 1)
@@ -922,8 +941,8 @@ prepare_text :: proc(layer: ^Layer, text: Text) {
// Snap base position to integer physical pixels to avoid atlas sub-pixel
// sampling blur (and the off-by-one bottom-row clip that comes with it).
base_x := math.round(text.position[0] * GLOB.dpi_scaling)
base_y := math.round(text.position[1] * GLOB.dpi_scaling)
base_x_ppx := math.round(text.position[0] * GLOB.dpi_scaling)
base_y_ppx := math.round(text.position[1] * GLOB.dpi_scaling)
// Premultiply text color once — reused across all glyph vertices.
pm_color := premultiply_color(text.color)
@@ -938,7 +957,7 @@ prepare_text :: proc(layer: ^Layer, text: Text) {
uv := data.uv[i]
append(
&GLOB.tmp_text_verts,
Vertex_2D{position = {pos.x + base_x, -pos.y + base_y}, uv = {uv.x, uv.y}, color = pm_color},
Vertex_2D{position = {pos.x + base_x_ppx, -pos.y + base_y_ppx}, uv = {uv.x, uv.y}, color = pm_color},
)
}
@@ -1079,7 +1098,7 @@ build_rrect_primitive :: proc(
radii: Rectangle_Radii,
origin: Vec2,
rotation: f32,
feather_px: f32,
feather_ppx: f32,
) -> Core_2D_Primitive {
max_radius := min(rect.width, rect.height) * 0.5
clamped_top_left := clamp(radii.top_left, 0, max_radius)
@@ -1087,8 +1106,8 @@ build_rrect_primitive :: proc(
clamped_bottom_right := clamp(radii.bottom_right, 0, max_radius)
clamped_bottom_left := clamp(radii.bottom_left, 0, max_radius)
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
half_feather_ppx := feather_ppx * 0.5
padding := half_feather_ppx / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
half_width := rect.width * 0.5
@@ -1126,14 +1145,14 @@ build_rrect_primitive :: proc(
rotation_sc = has_rotation ? pack_rotation_sc(sin_angle, cos_angle) : 0,
}
prim.params.rrect = RRect_Params {
half_size = {half_width * dpi_scale, half_height * dpi_scale},
radii = {
half_size_ppx = {half_width * dpi_scale, half_height * dpi_scale},
radii_ppx = {
clamped_bottom_right * dpi_scale,
clamped_top_right * dpi_scale,
clamped_bottom_left * dpi_scale,
clamped_top_left * dpi_scale,
},
half_feather = half_feather,
half_feather_ppx = half_feather_ppx,
}
return prim
}
@@ -1146,10 +1165,10 @@ build_circle_primitive :: proc(
radius: f32,
origin: Vec2,
rotation: f32,
feather_px: f32,
feather_ppx: f32,
) -> Core_2D_Primitive {
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
half_feather_ppx := feather_ppx * 0.5
padding := half_feather_ppx / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
actual_center := center
@@ -1166,11 +1185,11 @@ build_circle_primitive :: proc(
actual_center.y + radius + padding,
},
}
scaled_radius := radius * dpi_scale
radius_ppx := radius * dpi_scale
prim.params.rrect = RRect_Params {
half_size = {scaled_radius, scaled_radius},
radii = {scaled_radius, scaled_radius, scaled_radius, scaled_radius},
half_feather = half_feather,
half_size_ppx = {radius_ppx, radius_ppx},
radii_ppx = {radius_ppx, radius_ppx, radius_ppx, radius_ppx},
half_feather_ppx = half_feather_ppx,
}
return prim
}
@@ -1183,10 +1202,10 @@ build_ellipse_primitive :: proc(
radius_horizontal, radius_vertical: f32,
origin: Vec2,
rotation: f32,
feather_px: f32,
feather_ppx: f32,
) -> Core_2D_Primitive {
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
half_feather_ppx := feather_ppx * 0.5
padding := half_feather_ppx / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
actual_center := center
@@ -1218,8 +1237,8 @@ build_ellipse_primitive :: proc(
rotation_sc = has_rotation ? pack_rotation_sc(sin_angle, cos_angle) : 0,
}
prim.params.ellipse = Ellipse_Params {
radii = {radius_horizontal * dpi_scale, radius_vertical * dpi_scale},
half_feather = half_feather,
radii_ppx = {radius_horizontal * dpi_scale, radius_vertical * dpi_scale},
half_feather_ppx = half_feather_ppx,
}
return prim
}
@@ -1233,10 +1252,10 @@ build_polygon_primitive :: proc(
radius: f32,
origin: Vec2,
rotation: f32,
feather_px: f32,
feather_ppx: f32,
) -> Core_2D_Primitive {
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
half_feather_ppx := feather_ppx * 0.5
padding := half_feather_ppx / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
actual_center := center
@@ -1258,9 +1277,9 @@ build_polygon_primitive :: proc(
rotation_sc = rotation != 0 ? pack_rotation_sc(sin_rot, cos_rot) : 0,
}
prim.params.ngon = NGon_Params {
radius = radius * math.cos(math.PI / f32(sides)) * dpi_scale,
sides = f32(sides),
half_feather = half_feather,
radius_ppx = radius * math.cos(math.PI / f32(sides)) * dpi_scale,
sides = f32(sides),
half_feather_ppx = half_feather_ppx,
}
return prim
}
@@ -1278,13 +1297,13 @@ build_ring_arc_primitive :: proc(
end_angle: f32,
origin: Vec2,
rotation: f32,
feather_px: f32,
feather_ppx: f32,
) -> (
Core_2D_Primitive,
Shape_Flags,
) {
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
half_feather_ppx := feather_ppx * 0.5
padding := half_feather_ppx / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
actual_center := center
@@ -1327,11 +1346,11 @@ build_ring_arc_primitive :: proc(
},
}
prim.params.ring_arc = Ring_Arc_Params {
inner_radius = inner_radius * dpi_scale,
outer_radius = outer_radius * dpi_scale,
normal_start = normal_start,
normal_end = normal_end,
half_feather = half_feather,
inner_radius_ppx = inner_radius * dpi_scale,
outer_radius_ppx = outer_radius * dpi_scale,
normal_start = normal_start,
normal_end = normal_end,
half_feather_ppx = half_feather_ppx,
}
return prim, arc_flags
}
@@ -1422,9 +1441,9 @@ rectangle :: proc(
radii: Rectangle_Radii = {},
origin: Vec2 = {},
rotation: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
feather_ppx: f32 = DFT_FEATHER_PPX,
) {
prim := build_rrect_primitive(rect, radii, origin, rotation, feather_px)
prim := build_rrect_primitive(rect, radii, origin, rotation, feather_ppx)
apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width)
}
@@ -1445,9 +1464,9 @@ circle :: proc(
outline_width: f32 = 0,
origin: Vec2 = {},
rotation: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
feather_ppx: f32 = DFT_FEATHER_PPX,
) {
prim := build_circle_primitive(center, radius, origin, rotation, feather_px)
prim := build_circle_primitive(center, radius, origin, rotation, feather_ppx)
apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width)
}
@@ -1462,9 +1481,9 @@ ellipse :: proc(
outline_width: f32 = 0,
origin: Vec2 = {},
rotation: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
feather_ppx: f32 = DFT_FEATHER_PPX,
) {
prim := build_ellipse_primitive(center, radius_horizontal, radius_vertical, origin, rotation, feather_px)
prim := build_ellipse_primitive(center, radius_horizontal, radius_vertical, origin, rotation, feather_ppx)
apply_brush_and_outline(layer, &prim, .Ellipse, brush, outline_color, outline_width)
}
@@ -1481,11 +1500,11 @@ polygon :: proc(
outline_width: f32 = 0,
origin: Vec2 = {},
rotation: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
feather_ppx: f32 = DFT_FEATHER_PPX,
) {
if sides < 3 do return
prim := build_polygon_primitive(center, sides, radius, origin, rotation, feather_px)
prim := build_polygon_primitive(center, sides, radius, origin, rotation, feather_ppx)
apply_brush_and_outline(layer, &prim, .NGon, brush, outline_color, outline_width)
}
@@ -1504,7 +1523,7 @@ ring :: proc(
end_angle: f32 = DFT_CIRC_END_ANGLE,
origin: Vec2 = {},
rotation: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
feather_ppx: f32 = DFT_FEATHER_PPX,
) {
prim, arc_flags := build_ring_arc_primitive(
center,
@@ -1514,7 +1533,7 @@ ring :: proc(
end_angle,
origin,
rotation,
feather_px,
feather_ppx,
)
apply_brush_and_outline(layer, &prim, .Ring_Arc, brush, outline_color, outline_width, arc_flags)
}
@@ -1528,7 +1547,7 @@ line :: proc(
thickness: f32 = DFT_STROKE_THICKNESS,
outline_color: Color = {},
outline_width: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
feather_ppx: f32 = DFT_FEATHER_PPX,
) {
delta_x := end_position.x - start_position.x
delta_y := end_position.y - start_position.y
@@ -1544,8 +1563,8 @@ line :: proc(
half_thickness := thickness * 0.5
cap_radius := half_thickness
half_feather := feather_px * 0.5
padding := half_feather / GLOB.dpi_scaling
half_feather_ppx := feather_ppx * 0.5
padding := half_feather_ppx / GLOB.dpi_scaling
dpi_scale := GLOB.dpi_scaling
// Expand bounds for rotation
@@ -1561,14 +1580,14 @@ line :: proc(
rotation_sc = pack_rotation_sc(sin_angle, cos_angle),
}
prim.params.rrect = RRect_Params {
half_size = {(half_length + cap_radius) * dpi_scale, half_thickness * dpi_scale},
radii = {
half_size_ppx = {(half_length + cap_radius) * dpi_scale, half_thickness * dpi_scale},
radii_ppx = {
cap_radius * dpi_scale,
cap_radius * dpi_scale,
cap_radius * dpi_scale,
cap_radius * dpi_scale,
},
half_feather = half_feather,
half_feather_ppx = half_feather_ppx,
}
apply_brush_and_outline(layer, &prim, .RRect, brush, outline_color, outline_width)
}
@@ -1581,10 +1600,10 @@ line_strip :: proc(
thickness: f32 = DFT_STROKE_THICKNESS,
outline_color: Color = {},
outline_width: f32 = 0,
feather_px: f32 = DFT_FEATHER_PX,
feather_ppx: f32 = DFT_FEATHER_PPX,
) {
if len(points) < 2 do return
for i in 0 ..< len(points) - 1 {
line(layer, points[i], points[i + 1], brush, thickness, outline_color, outline_width, feather_px)
line(layer, points[i], points[i + 1], brush, thickness, outline_color, outline_width, feather_ppx)
}
}
+4 -4
View File
@@ -747,10 +747,10 @@ PRE_PAD_X :: SPACE_PANEL // 24
// ============================================================
// SCANLINE OVERLAY (opt-in, terminal surfaces only)
// Repeating-stripe pattern at very low opacity. Stripe is 2px
// transparent + 2px black-at-3% (TINT_SCANLINE).
// Repeating-stripe pattern at very low opacity. Stripe is 2 logical
// pixels transparent + 2 logical pixels black-at-3% (TINT_SCANLINE).
// ============================================================
SCANLINE_STRIPE_PX :: 2
SCANLINE_GAP_PX :: 2
SCANLINE_STRIPE_LPX :: 2
SCANLINE_GAP_LPX :: 2
SCANLINE_COLOR :: TINT_SCANLINE
+216 -131
View File
@@ -1,3 +1,66 @@
// Rendering library built on SDL3 GPU.
//
// ----- Coordinate system -----
// Origin is the top-left corner of the window/layer. X increases rightward, Y increases
// downward. This matches SDL, HTML Canvas, and most 2D UI coordinate conventions. All
// public position parameters (`center`, `origin`, `start_position`, `end_position`, every
// `Vec2`-typed field, every `Rectangle.x/y`, etc.) live in this coordinate system.
//
// ----- Unit-suffix convention -----
// Public CPU-side dimensions are in *logical* pixels by default (CSS-style: a value of 200
// looks the same physical size on a 1× monitor and a 2× Retina display). Suffix rules:
//
// no suffix — logical pixels. Default for layout values (positions, sizes, radii,
// outline widths, line thicknesses, gradient endpoints, etc.).
// `_lpx` — logical pixels, *explicit*. Optional. Use when an identifier would
// otherwise be ambiguous about which kind of pixel it carries —
// typically standalone constants like `SCANLINE_STRIPE_LPX` where the
// context doesn't make the unit obvious from the surrounding code.
// Procedure parameters and struct fields named after a layout property
// (`width`, `radius`, ...) don't need this suffix.
// `_ppx` — physical (device) pixels. Required whenever a value is in physical
// pixels, regardless of context. Reserved for quantities whose
// right-feeling magnitude is a property of the device pixel grid rather
// than of the layout: anti-aliasing band widths, sub-pixel snap targets,
// MSDF screen-pixel-range parameters.
//
// Examples:
//
// width, height, radius, outline_width, thickness — logical px (no suffix)
// SCANLINE_STRIPE_LPX, SCANLINE_GAP_LPX — logical px (explicit `_lpx`)
// feather_ppx, aa_ppx — physical px (`_ppx`)
//
// Layout values scale with DPI; rasterization-grid values do not. The shader handles the
// logical-to-physical conversion at the rasterization boundary; CPU-side `_ppx` inputs that
// need to interact with logical-space data convert via `/ dpi_scaling` at the use site.
//
// ----- Anti-aliasing -----
// MSAA is intentionally NOT supported. SDF text and shapes compute fragment coverage
// analytically via `smoothstep`, so they don't benefit from multisampling. Tessellated
// user geometry submitted via `prepare_shape` is rendered without anti-aliasing — if AA is
// required for tessellated content, the caller must either render it to their own offscreen
// target and submit the result as a texture, or use the AA helpers in the `tess` subpackage
// (e.g. `tess.triangle_aa` extrudes 1-physical-pixel alpha-falloff edge bands). This
// decision aligns with the SBC target (Mali Valhall, where MSAA's per-tile bandwidth
// multiplier is expensive) and matches RAD Debugger's architecture.
//
// ----- Color and blending -----
// `Color` is RGBA8 in memory order (R, G, B, A at indices 0..3). The shader unpacks via
// `unpackUnorm4x8`, which reads bytes in that exact order. Alpha 255 = fully opaque, 0 =
// fully transparent.
//
// All rendering uses *premultiplied-over* blending (blend state ONE, ONE_MINUS_SRC_ALPHA —
// the standard mode used by Skia, Flutter, and GPUI). Three implications:
//
// - Public shape procs (`rectangle`, `circle`, `line`, etc.) accept straight-alpha
// `Color` values and the SDF fragment shaders premultiply internally; users of these
// procs don't need to think about premultiplication.
// - Vertex colors written to the shared vertex stream (the tessellated path — text and
// anything submitted via `prepare_shape`, including `tess.*` helpers) MUST be
// premultiplied at the CPU. The tessellated fragment shader passes vertex color through
// directly without further modification. The `premultiply_color` helper handles this.
// - The clear color passed to `end()` is also premultiplied internally before being
// handed to the GPU; callers pass straight-alpha `Color` here too.
package draw
import "base:runtime"
@@ -51,7 +114,7 @@ INITIAL_SCISSOR_SIZE :: 10
// ----- Default parameter values -----
// Named constants for non-zero default procedure parameters. Centralizes magic numbers
// so they can be tuned in one place and referenced by name in proc signatures.
DFT_FEATHER_PX :: 1 // Total AA feather width in physical pixels (half on each side of boundary).
DFT_FEATHER_PPX :: 1 // Total AA feather width in physical pixels (half on each side of boundary).
DFT_STROKE_THICKNESS :: 1 // Default line/stroke thickness in logical pixels.
DFT_FONT_SIZE :: 44 // Default font size in points for text rendering.
DFT_CIRC_END_ANGLE :: 360 // Full-circle end angle in degrees (ring/arc).
@@ -132,27 +195,13 @@ Global :: struct {
// ---------------------------------------------------------------------------------------------------------------------
// A 2D position in world space. Non-distinct alias for [2]f32 — bare literals like {100, 200}
// work at non-ambiguous call sites.
//
// Coordinate system: origin is the top-left corner of the window/layer. X increases rightward,
// Y increases downward. This matches SDL, HTML Canvas, and most 2D UI coordinate conventions.
// All position parameters in the draw API (center, origin, start_position, end_position, etc.)
// use this coordinate system.
//
// Units are logical pixels (pre-DPI-scaling). The renderer multiplies by dpi_scaling internally
// before uploading to the GPU. A Vec2{100, 50} refers to the same visual location regardless of
// display DPI.
// work at non-ambiguous call sites. See the package doc for coordinate-system and unit
// conventions.
Vec2 :: [2]f32
// An RGBA color with 8 bits per channel. Distinct type over [4]u8 so that proc-group
// overloads can disambiguate Color from other 4-byte structs.
//
// Channel order: R, G, B, A (indices 0, 1, 2, 3). Alpha 255 is fully opaque, 0 is fully
// transparent. This matches the GPU-side layout: the shader unpacks via unpackUnorm4x8 which
// reads the bytes in memory order as R, G, B, A and normalizes each to [0, 1].
//
// When used in the Core_2D_Primitive or Gaussian_Blur_Primitive structs (e.g. .color), the 4 bytes
// are stored as a u32 in native byte order and unpacked by the shader.
// overloads can disambiguate Color from other 4-byte structs. See the package doc for the
// memory layout and the premultiplied-over blending contract.
Color :: [4]u8
BLACK :: Color{0, 0, 0, 255}
@@ -228,10 +277,9 @@ color_to_f32 :: proc(color: Color) -> [4]f32 {
return {f32(color[0]) * INV, f32(color[1]) * INV, f32(color[2]) * INV, f32(color[3]) * INV}
}
// Pre-multiply RGB channels by alpha. The tessellated vertex path and text path require
// premultiplied colors because the blend state is ONE, ONE_MINUS_SRC_ALPHA and the
// tessellated fragment shader passes vertex color through without further modification.
// Users who construct Vertex_2D structs manually for prepare_shape must premultiply their colors.
// Pre-multiply RGB channels by alpha. Required for any vertex written to the tessellated
// vertex stream (text path or `prepare_shape`-style submissions); see the package doc's
// "Color and blending" section for the full contract.
premultiply_color :: #force_inline proc(color: Color) -> Color {
a := u32(color[3])
return Color {
@@ -249,7 +297,7 @@ premultiply_color :: #force_inline proc(color: Color) -> Color {
//INTERNAL
Sub_Batch_Kind :: enum u8 {
Tessellated, // non-indexed, white texture or user texture, Core_2D_Mode.Tessellated
Text, // indexed, atlas texture, Core_2D_Mode.Tessellated
Text, // indexed, atlas texture, Core_2D_Mode.Text (vertices already in physical-pixel space)
SDF, // instanced unit quad, Core_2D_Mode.SDF
// instanced unit quad, backdrop subsystem V-composite (indexes Gaussian_Blur_Primitive).
// Bracket-scheduled per layer; see README.md § "Backdrop pipeline" for ordering semantics.
@@ -289,12 +337,6 @@ Scissor :: struct {
// ---------------------------------------------------------------------------------------------------------------------
// Initialize the renderer. Returns false if GPU pipeline or text engine creation fails.
//
// MSAA is intentionally NOT supported. SDF text and shapes compute coverage analytically via
// `smoothstep`, so they don't benefit from multisampling. Tessellated user geometry submitted
// via `prepare_shape` is not anti-aliased — if you need AA on tessellated content, render it
// to your own offscreen target and submit it as a texture. RAD Debugger and the SBC target
// (Mali Valhall, where MSAA's per-tile bandwidth multiplier is expensive) drove this decision.
@(require_results)
init :: proc(
device: ^sdl.GPUDevice,
@@ -446,30 +488,6 @@ clear_global :: proc() {
// ----- Frame ------------
// ---------------------------------------------------------------------------------------------------------------------
// Sets up renderer to begin upload to the GPU. Returns starting `Layer` to begin processing primitives for.
begin :: proc(bounds: Rectangle) -> ^Layer {
// Cleanup
clear_global()
// Begin new layer + start a new scissor
scissor := Scissor {
bounds = sdl.Rect {
x = i32(bounds.x * GLOB.dpi_scaling),
y = i32(bounds.y * GLOB.dpi_scaling),
w = i32(bounds.width * GLOB.dpi_scaling),
h = i32(bounds.height * GLOB.dpi_scaling),
},
}
append(&GLOB.scissors, scissor)
layer := Layer {
bounds = bounds,
scissor_len = 1,
}
append(&GLOB.layers, layer)
return &GLOB.layers[GLOB.curr_layer_index]
}
// Creates a new layer
new_layer :: proc(prev_layer: ^Layer, bounds: Rectangle) -> ^Layer {
if GLOB.open_backdrop_layer != nil {
@@ -499,44 +517,28 @@ new_layer :: proc(prev_layer: ^Layer, bounds: Rectangle) -> ^Layer {
return &GLOB.layers[GLOB.curr_layer_index]
}
// Open a backdrop scope on `layer`. All subsequent draws on `layer` until the matching
// `end_backdrop` must be backdrop primitives (currently only `backdrop_blur`). Non-backdrop
// draws inside a scope, or backdrop draws outside one, panic.
//
// Bracket scheduling: each scope produces one bracket at render time. Within the scope,
// per-sigma sub-batch coalescing still applies (two contiguous backdrop_blur calls with
// the same sigma share an instanced composite draw and a single H+V blur pass pair).
//
// Multiple begin/end pairs per layer are allowed: each pair is its own bracket, and
// non-backdrop draws between pairs render in their submission position relative to the
// brackets. Use this for layered frost effects.
begin_backdrop :: proc(layer: ^Layer) {
if GLOB.open_backdrop_layer != nil {
log.panicf("begin_backdrop called while a scope is already open on layer %p", GLOB.open_backdrop_layer)
}
GLOB.open_backdrop_layer = layer
}
// Sets up renderer to begin upload to the GPU. Returns starting `Layer` to begin processing primitives for.
begin :: proc(bounds: Rectangle) -> ^Layer {
// Cleanup
clear_global()
// Close the backdrop scope opened by `begin_backdrop`. Must be called on the same layer that
// the scope was opened on; the layer pointer mismatch is a hard error rather than a silent
// recovery to surface integration bugs early.
end_backdrop :: proc(layer: ^Layer) {
if GLOB.open_backdrop_layer != layer {
log.panicf("end_backdrop on wrong layer (open=%p, ended=%p)", GLOB.open_backdrop_layer, layer)
// Begin new layer + start a new scissor
scissor := Scissor {
bounds = sdl.Rect {
x = i32(bounds.x * GLOB.dpi_scaling),
y = i32(bounds.y * GLOB.dpi_scaling),
w = i32(bounds.width * GLOB.dpi_scaling),
h = i32(bounds.height * GLOB.dpi_scaling),
},
}
GLOB.open_backdrop_layer = nil
}
append(&GLOB.scissors, scissor)
// Convenience wrapper for the common case of a backdrop scope tied to a block. Use with
// defer-style block scoping:
//
// {
// draw.backdrop_scope(layer)
// draw.backdrop_blur(layer, ...)
// } // end_backdrop fires automatically
@(deferred_in = end_backdrop)
backdrop_scope :: #force_inline proc(layer: ^Layer) {
begin_backdrop(layer)
layer := Layer {
bounds = bounds,
scissor_len = 1,
}
append(&GLOB.layers, layer)
return &GLOB.layers[GLOB.curr_layer_index]
}
// Render primitives. clear_color is the background fill before any layers are drawn.
@@ -625,6 +627,46 @@ end :: proc(device: ^sdl.GPUDevice, window: ^sdl.Window, clear_color: Color = DF
}
}
// Open a backdrop scope on `layer`. All subsequent draws on `layer` until the matching
// `end_backdrop` must be backdrop primitives (currently only `backdrop_blur`). Non-backdrop
// draws inside a scope, or backdrop draws outside one, panic.
//
// Bracket scheduling: each scope produces one bracket at render time. Within the scope,
// per-sigma sub-batch coalescing still applies (two contiguous backdrop_blur calls with
// the same sigma share an instanced composite draw and a single H+V blur pass pair).
//
// Multiple begin/end pairs per layer are allowed: each pair is its own bracket, and
// non-backdrop draws between pairs render in their submission position relative to the
// brackets. Use this for layered frost effects.
begin_backdrop :: proc(layer: ^Layer) {
if GLOB.open_backdrop_layer != nil {
log.panicf("begin_backdrop called while a scope is already open on layer %p", GLOB.open_backdrop_layer)
}
GLOB.open_backdrop_layer = layer
}
// Close the backdrop scope opened by `begin_backdrop`. Must be called on the same layer that
// the scope was opened on; the layer pointer mismatch is a hard error rather than a silent
// recovery to surface integration bugs early.
end_backdrop :: proc(layer: ^Layer) {
if GLOB.open_backdrop_layer != layer {
log.panicf("end_backdrop on wrong layer (open=%p, ended=%p)", GLOB.open_backdrop_layer, layer)
}
GLOB.open_backdrop_layer = nil
}
// Convenience wrapper for the common case of a backdrop scope tied to a block. Use with
// defer-style block scoping:
//
// {
// draw.backdrop_scope(layer)
// draw.backdrop_blur(layer, ...)
// } // end_backdrop fires automatically
@(deferred_in = end_backdrop)
backdrop_scope :: #force_inline proc(layer: ^Layer) {
begin_backdrop(layer)
}
// ---------------------------------------------------------------------------------------------------------------------
// ----- Sub-batch dispatch ------------
// ---------------------------------------------------------------------------------------------------------------------
@@ -712,14 +754,18 @@ measure_text_clay :: proc "c" (
}
// Called for each Clay `RenderCommandType.Custom` render command that
// `prepare_clay_batch` encounters.
// `prepare_clay_batch` encounters and which is NOT a levlib-managed variant
// (e.g. `Backdrop_Marker`).
//
// - `layer` is the layer the command belongs to (post-z-index promotion).
// - `bounds` is already translated into the active layer's coordinate system
// and pre-DPI, matching what the built-in shape procs expect.
// - `render_data` is Clay's `CustomRenderData` for the element, exposing
// `backgroundColor`, `cornerRadius`, and the `customData` pointer the caller
// attached to `clay.CustomElementConfig.customData`.
// `backgroundColor` and `cornerRadius`. Its `customData` field has been
// unwrapped from the `Clay_Custom` envelope: it points at the user's own
// data (the value the user wrote into the `rawptr` variant), not at the
// `Clay_Custom` itself. If the union was zero-init (no variant set) or
// `customData` was originally nil, the callback receives nil.
//
// The callback must not call `new_layer` or `prepare_clay_batch`.
Custom_Draw :: #type proc(layer: ^Layer, bounds: Rectangle, render_data: clay.CustomRenderData)
@@ -729,33 +775,51 @@ ClayBatch :: struct {
cmds: clay.ClayArray(clay.RenderCommand),
}
// Magic-number-tagged struct that user app data points at via Clay's customData field.
// `prepare_clay_batch` recognizes these and routes them through a backdrop scope automatically.
// The user populates a `Backdrop_Marker`, points `clay.CustomElementConfig.customData` at it,
// and the integration walks the command stream, opening/closing scopes around contiguous
// backdrop runs. Magic-number sentinel chosen over a separate userData flag so the marker
// type stays self-describing in core dumps and in any non-Odin debugger view of the heap.
Backdrop_Marker :: struct {
magic: u32,
sigma: f32,
tint: Color,
radii: Rectangle_Radii,
feather_px: f32,
// Discriminated sum of everything `clay.CustomElementConfig.customData` is allowed to point
// at. levlib-defined variants (currently just `Backdrop_Marker`) are recognized by
// `prepare_clay_batch` and routed to the appropriate internal path; the `rawptr` variant is
// the escape hatch for user-defined custom drawing — `prepare_clay_batch` unwraps it before
// invoking `custom_draw` so the callback sees the user's pointer in `render_data.customData`
// exactly as if no wrapper were involved.
//
// Contract: `customData`, when non-nil, MUST point at storage holding a `Clay_Custom`
// value. The user owns that storage; its lifetime must span the Clay layout call and the
// matching `prepare_clay_batch` call. Pointing `customData` at a bare user struct violates
// the contract — the dispatcher will read its first bytes as a union tag and either route
// the draw incorrectly or panic on type assertion. There is no recovery path; this is a
// strict-discipline API by design.
//
// Construction notes (Odin implicit-conversion rules):
// - Backdrop variant: `bd: Clay_Custom = Backdrop_Marker{...}` works directly.
// Variant-to-union conversion is implicit.
// - User pointer: `up: Clay_Custom = rawptr(&my_struct)` — the explicit `rawptr(...)` is
// required because Odin does not chain `^T -> rawptr -> Clay_Custom` implicitly. A bare
// `up: Clay_Custom = &my_struct` is a compile error.
Clay_Custom :: union {
Backdrop_Marker,
rawptr,
}
// 'BDPT' in big-endian ASCII. Picked for greppability and to be obviously non-zero in
// uninitialized memory; user code that forgets to set the magic field gets routed through
// the regular custom_draw path and surfaces as "my custom draw never fired," not as a
// silent backdrop schedule.
BACKDROP_MARKER_MAGIC :: u32(0x42445054)
// Per-primitive parameters for a backdrop blur dispatched through the Clay integration.
// Embedded as a `Clay_Custom` variant; `prepare_clay_batch` walks the command stream,
// opens/closes a backdrop scope around contiguous backdrop runs, and feeds these to
// `backdrop_blur` via `dispatch_clay_backdrop`. The discriminant is the union tag — no
// in-band magic field needed (compiler-enforced).
Backdrop_Marker :: struct {
sigma: f32,
tint: Color,
radii: Rectangle_Radii,
feather_ppx: f32,
}
// Returns true if this Clay render command represents a backdrop primitive.
// Identified by a magic-number sentinel in the first 4 bytes of customData.
// Returns true if this Clay render command represents a backdrop primitive — i.e. its
// `customData` points at a `Clay_Custom` whose active variant is `Backdrop_Marker`.
is_clay_backdrop :: proc(cmd: ^clay.RenderCommand) -> bool {
if cmd.commandType != .Custom do return false
p := cmd.renderData.custom.customData
if p == nil do return false
return (^Backdrop_Marker)(p).magic == BACKDROP_MARKER_MAGIC
_, ok := (^Clay_Custom)(p).(Backdrop_Marker)
return ok
}
// Dispatch a single non-backdrop Clay render command to the appropriate `draw` primitive.
@@ -876,28 +940,46 @@ dispatch_clay_command :: proc(
}
rectangle(layer, bounds, BLANK, outline_color = color, outline_width = thickness, radii = radii)
case clay.RenderCommandType.Custom: if is_clay_backdrop(render_command) {
// The walker pre-filters backdrops into `dispatch_clay_backdrop` and never feeds
// them here; reaching this branch means either the walker logic is broken or the
// `customData` pointee mutated between the walker's `is_clay_backdrop` check and
// this re-check (heap corruption / lifetime bug in user-managed customData
// memory). Both are renderer-level bugs that warrant a hard failure rather than a
// silently-dropped panel.
log.panicf(
"backdrop marker reached dispatch_clay_command; either the prepare_clay_batch walker is misrouting commands or the customData pointee at %p was mutated mid-frame",
render_command.renderData.custom.customData,
)
} else if custom_draw != nil {
custom_draw(layer, bounds, render_command.renderData.custom)
} else {
log.panicf("Received clay render command of type custom but no custom_draw proc provided.")
case clay.RenderCommandType.Custom:
// Copy the CustomRenderData by value so we can patch its `customData` field for the
// user callback without mutating Clay-owned memory. After unwrapping, the callback
// sees its own pointer in `render_data.customData`, identical to what it would see
// if `Clay_Custom` did not exist as an intermediary.
patched := render_command.renderData.custom
// Default to nil so a zero-init `Clay_Custom` (no variant set) and an originally-nil
// `customData` both surface to the callback as `customData = nil`.
patched.customData = nil
if custom_data_pointer := render_command.renderData.custom.customData; custom_data_pointer != nil {
switch custom_value in (^Clay_Custom)(custom_data_pointer)^ {
case Backdrop_Marker: // The walker pre-filters backdrops into `dispatch_clay_backdrop` and never feeds
// them here; reaching this branch means either the walker logic is broken or the
// `Clay_Custom` variant tag mutated between the walker's `is_clay_backdrop` check
// and this re-check (heap corruption / lifetime bug in user-managed customData
// memory). Both are renderer-level bugs that warrant a hard failure rather than a
// silently-dropped panel.
log.panicf(
"backdrop marker reached dispatch_clay_command; either the prepare_clay_batch walker is misrouting commands or the customData pointee at %p was mutated mid-frame",
render_command.renderData.custom.customData,
)
case rawptr: patched.customData = custom_value
}
}
if custom_draw != nil {
custom_draw(layer, bounds, patched)
} else if patched.customData != nil {
log.panicf(
"Received clay render command of type custom with non-nil user data but no custom_draw proc provided.",
)
}
}
}
// Dispatch a single backdrop Clay render command to `backdrop_blur` on the active layer.
// Caller guarantees a backdrop scope is open on `layer` so the underlying
// `append_or_extend_sub_batch` contract assertion is satisfied.
// Caller guarantees:
// - a backdrop scope is open on `layer` so the underlying `append_or_extend_sub_batch`
// contract assertion is satisfied;
// - the command's `customData` points at a `Clay_Custom` whose active variant is
// `Backdrop_Marker` (the walker has already verified this via `is_clay_backdrop`).
//INTERNAL
dispatch_clay_backdrop :: proc(layer: ^Layer, cmd: ^clay.RenderCommand) {
bounds := Rectangle {
@@ -906,14 +988,17 @@ dispatch_clay_backdrop :: proc(layer: ^Layer, cmd: ^clay.RenderCommand) {
width = cmd.boundingBox.width,
height = cmd.boundingBox.height,
}
marker := (^Backdrop_Marker)(cmd.renderData.custom.customData)
// Type-asserting form (no `, ok`): panics loudly if the variant tag changed since
// `is_clay_backdrop`, which is the desired tripwire for a heap-corruption bug in
// user-managed customData.
marker := (^Clay_Custom)(cmd.renderData.custom.customData).(Backdrop_Marker)
backdrop_blur(
layer,
bounds,
gaussian_sigma = marker.sigma,
tint = marker.tint,
radii = marker.radii,
feather_px = marker.feather_px,
feather_ppx = marker.feather_ppx,
)
}
+40 -4
View File
@@ -63,7 +63,7 @@ hellope_shapes :: proc() {
outline_width = 2,
origin = draw.center_of(rect),
rotation = spin_angle,
feather_px = 1,
feather_ppx = 1,
)
// Rounded rectangle rotating around its center
@@ -287,6 +287,22 @@ hellope_custom :: proc() {
value = 0.45,
color = {200, 100, 50, 255},
}
// `clay.CustomElementConfig.customData` is a rawptr; the Clay integration in `draw`
// requires it to point at a `Clay_Custom` value. The explicit `rawptr(...)` cast is
// necessary because Odin does not chain `^Gauge -> rawptr -> Clay_Custom` implicitly
// (variant-to-union and ^T-to-rawptr are each implicit on their own, but not stacked).
gauge_custom: draw.Clay_Custom = rawptr(&gauge)
gauge2_custom: draw.Clay_Custom = rawptr(&gauge2)
// Backdrop variant: variant-to-union conversion is implicit, so no cast needed.
// `tint = draw.WHITE` is the no-op tint per the backdrop module's convention
// (matches `examples/backdrop.odin`'s "pure blur, no color" usage).
backdrop_custom: draw.Clay_Custom = draw.Backdrop_Marker {
sigma = 8,
tint = draw.WHITE,
}
spin_angle: f32 = 0
for {
@@ -320,20 +336,37 @@ hellope_custom :: proc() {
clay.Text("Custom Draw Demo", &text_config)
}
// gauge1 is BEHIND the backdrop — the backdrop is declared as a floating CHILD
// of gauge1, pinned to gauge1's LeftTop and sized 300x30 so it covers exactly
// gauge1's footprint. Clay emits a floating child's render command after the
// parent's, so the stream order is gauge1 → backdrop → gauge2: gauge1's pixels
// land in `source_texture` before the bracket samples (visible as a blurred
// reflection inside the strip), and gauge2 is deferred-replayed by
// `prepare_clay_batch` after the bracket closes (renders crisp on top of the
// bracket output — unrelated to the strip since they don't overlap).
if clay.UI()(
{
id = clay.ID("gauge"),
layout = {sizing = {clay.SizingFixed(300), clay.SizingFixed(30)}},
custom = {customData = &gauge},
custom = {customData = &gauge_custom},
backgroundColor = {80, 80, 80, 255},
},
) {}
) {
if clay.UI()(
{
id = clay.ID("backdrop"),
floating = {attachTo = .Parent, attachment = {parent = .LeftTop, element = .LeftTop}},
layout = {sizing = {clay.SizingFixed(300), clay.SizingFixed(30)}},
custom = {customData = &backdrop_custom},
},
) {}
}
if clay.UI()(
{
id = clay.ID("gauge2"),
layout = {sizing = {clay.SizingFixed(300), clay.SizingFixed(30)}},
custom = {customData = &gauge2},
custom = {customData = &gauge2_custom},
backgroundColor = {80, 80, 80, 255},
},
) {}
@@ -353,6 +386,9 @@ hellope_custom :: proc() {
}
draw_custom :: proc(layer: ^draw.Layer, bounds: draw.Rectangle, render_data: clay.CustomRenderData) {
// `render_data.customData` has been unwrapped from the `Clay_Custom` envelope by
// `prepare_clay_batch` — it points at the Gauge directly, the same as it would have
// before the union refactor.
gauge := cast(^Gauge)render_data.customData
border_width: f32 = 2
@@ -25,9 +25,9 @@ struct main0_in
{
float2 p_local [[user(locn0)]];
float4 f_color [[user(locn1)]];
float2 f_half_size [[user(locn2), flat]];
float4 f_radii [[user(locn3), flat]];
float f_half_feather [[user(locn4), flat]];
float2 f_half_size_ppx [[user(locn2), flat]];
float4 f_radii_ppx [[user(locn3), flat]];
float f_half_feather_ppx [[user(locn4), flat]];
};
static inline __attribute__((always_inline))
@@ -96,16 +96,16 @@ fragment main0_out main0(main0_in in [[stage_in]], constant Uniforms& _108 [[buf
return out;
}
float2 param_1 = in.p_local;
float2 param_2 = in.f_half_size;
float4 param_3 = in.f_radii;
float2 param_2 = in.f_half_size_ppx;
float4 param_3 = in.f_radii_ppx;
float d = sdRoundedBox(param_1, param_2, param_3);
if (d > in.f_half_feather)
if (d > in.f_half_feather_ppx)
{
discard_fragment();
}
float grad_magnitude = fast::max(fwidth(d), 9.9999999747524270787835121154785e-07);
float d_n = d / grad_magnitude;
float h_n = in.f_half_feather / grad_magnitude;
float h_n = in.f_half_feather_ppx / grad_magnitude;
float2 uv_1 = (gl_FragCoord.xy * _108.inv_downsample_factor) * _108.inv_working_size;
float3 color_1 = blur_input_tex.sample(blur_input_texSmplr, uv_1).xyz;
float3 tinted = mix(color_1, color_1 * in.f_color.xyz, float3(in.f_color.w));
Binary file not shown.
+18 -18
View File
@@ -55,18 +55,18 @@ struct Uniforms
struct Gaussian_Blur_Primitive
{
float4 bounds;
float4 radii;
float2 half_size;
float half_feather;
float4 radii_ppx;
float2 half_size_ppx;
float half_feather_ppx;
uint color;
};
struct Gaussian_Blur_Primitive_1
{
float4 bounds;
float4 radii;
float2 half_size;
float half_feather;
float4 radii_ppx;
float2 half_size_ppx;
float half_feather_ppx;
uint color;
};
@@ -81,9 +81,9 @@ struct main0_out
{
float2 p_local [[user(locn0)]];
float4 f_color [[user(locn1)]];
float2 f_half_size [[user(locn2)]];
float4 f_radii [[user(locn3)]];
float f_half_feather [[user(locn4)]];
float2 f_half_size_ppx [[user(locn2)]];
float4 f_radii_ppx [[user(locn3)]];
float f_half_feather_ppx [[user(locn4)]];
float4 gl_Position [[position]];
};
@@ -96,26 +96,26 @@ vertex main0_out main0(constant Uniforms& _13 [[buffer(0)]], const device Gaussi
out.gl_Position = float4(ndc, 0.0, 1.0);
out.p_local = float2(0.0);
out.f_color = float4(0.0);
out.f_half_size = float2(0.0);
out.f_radii = float4(0.0);
out.f_half_feather = 0.0;
out.f_half_size_ppx = float2(0.0);
out.f_radii_ppx = float4(0.0);
out.f_half_feather_ppx = 0.0;
}
else
{
Gaussian_Blur_Primitive p;
p.bounds = _69.primitives[int(gl_InstanceIndex)].bounds;
p.radii = _69.primitives[int(gl_InstanceIndex)].radii;
p.half_size = _69.primitives[int(gl_InstanceIndex)].half_size;
p.half_feather = _69.primitives[int(gl_InstanceIndex)].half_feather;
p.radii_ppx = _69.primitives[int(gl_InstanceIndex)].radii_ppx;
p.half_size_ppx = _69.primitives[int(gl_InstanceIndex)].half_size_ppx;
p.half_feather_ppx = _69.primitives[int(gl_InstanceIndex)].half_feather_ppx;
p.color = _69.primitives[int(gl_InstanceIndex)].color;
float2 corner = _97[int(gl_VertexIndex)];
float2 world_pos = mix(p.bounds.xy, p.bounds.zw, corner);
float2 center = (p.bounds.xy + p.bounds.zw) * 0.5;
out.p_local = (world_pos - center) * _13.dpi_scale;
out.f_color = unpack_unorm4x8_to_float(p.color);
out.f_half_size = p.half_size;
out.f_radii = p.radii;
out.f_half_feather = p.half_feather;
out.f_half_size_ppx = p.half_size_ppx;
out.f_radii_ppx = p.radii_ppx;
out.f_half_feather_ppx = p.half_feather_ppx;
out.gl_Position = _13.projection * float4(world_pos * _13.dpi_scale, 0.0, 1.0);
}
return out;
Binary file not shown.
+24 -24
View File
@@ -107,57 +107,57 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d<float> tex [[textur
}
float d = 1000000015047466219876688855040.0;
float h = 0.5;
float2 half_size = in.f_params.xy;
float2 p_local = in.f_local_or_uv;
float2 half_size_ppx = in.f_params.xy;
float2 p_local_ppx = in.f_local_or_uv;
if (kind == 1u)
{
float4 corner_radii = float4(in.f_params.zw, in.f_params2.xy);
float4 corner_radii_ppx = float4(in.f_params.zw, in.f_params2.xy);
h = in.f_params2.z;
float2 param = p_local;
float2 param_1 = half_size;
float4 param_2 = corner_radii;
float2 param = p_local_ppx;
float2 param_1 = half_size_ppx;
float4 param_2 = corner_radii_ppx;
d = sdRoundedBox(param, param_1, param_2);
}
else
{
if (kind == 2u)
{
float radius = in.f_params.x;
float radius_ppx = in.f_params.x;
float sides = in.f_params.y;
h = in.f_params.z;
float2 param_3 = p_local;
float param_4 = radius;
float2 param_3 = p_local_ppx;
float param_4 = radius_ppx;
float param_5 = sides;
d = sdRegularPolygon(param_3, param_4, param_5);
half_size = float2(radius);
half_size_ppx = float2(radius_ppx);
}
else
{
if (kind == 3u)
{
float2 ab = in.f_params.xy;
float2 radii_ppx = in.f_params.xy;
h = in.f_params.z;
float2 param_6 = p_local;
float2 param_7 = ab;
float2 param_6 = p_local_ppx;
float2 param_7 = radii_ppx;
d = sdEllipseApprox(param_6, param_7);
half_size = ab;
half_size_ppx = radii_ppx;
}
else
{
if (kind == 4u)
{
float inner = in.f_params.x;
float outer = in.f_params.y;
float inner_radius_ppx = in.f_params.x;
float outer_radius_ppx = in.f_params.y;
float2 n_start = in.f_params.zw;
float2 n_end = in.f_params2.xy;
uint arc_bits = (flags >> 5u) & 3u;
h = in.f_params2.z;
float r = length(p_local);
d = fast::max(inner - r, r - outer);
float r = length(p_local_ppx);
d = fast::max(inner_radius_ppx - r, r - outer_radius_ppx);
if (arc_bits != 0u)
{
float d_start = dot(p_local, n_start);
float d_end = dot(p_local, n_end);
float d_start = dot(p_local_ppx, n_start);
float d_end = dot(p_local_ppx, n_end);
float _338;
if (arc_bits == 1u)
{
@@ -170,7 +170,7 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d<float> tex [[textur
float d_wedge = _338;
d = fast::max(d, d_wedge);
}
half_size = float2(outer);
half_size_ppx = float2(outer_radius_ppx);
}
}
}
@@ -185,7 +185,7 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d<float> tex [[textur
float4 gradient_end = unpack_unorm4x8_to_float(in.f_effects.x);
if ((flags & 4u) != 0u)
{
float t_1 = length(p_local / half_size);
float t_1 = length(p_local_ppx / half_size_ppx);
float4 param_8 = gradient_start;
float4 param_9 = gradient_end;
float param_10 = t_1;
@@ -194,7 +194,7 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d<float> tex [[textur
else
{
float2 direction = float2(as_type<half2>(in.f_effects.z));
float t_2 = (dot(p_local / half_size, direction) * 0.5) + 0.5;
float t_2 = (dot(p_local_ppx / half_size_ppx, direction) * 0.5) + 0.5;
float4 param_11 = gradient_start;
float4 param_12 = gradient_end;
float param_13 = t_2;
@@ -206,7 +206,7 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d<float> tex [[textur
if ((flags & 1u) != 0u)
{
float4 uv_rect = in.f_uv_rect;
float2 local_uv = ((p_local / half_size) * 0.5) + float2(0.5);
float2 local_uv = ((p_local_ppx / half_size_ppx) * 0.5) + float2(0.5);
float2 uv = mix(uv_rect.xy, uv_rect.zw, local_uv);
shape_color = in.f_color * tex.sample(texSmplr, uv);
}
Binary file not shown.
+32 -22
View File
@@ -60,32 +60,21 @@ struct main0_in
float4 v_color [[attribute(2)]];
};
vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer(0)]], const device Core_2D_Primitives& _75 [[buffer(1)]], uint gl_InstanceIndex [[instance_id]])
vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer(0)]], const device Core_2D_Primitives& _31 [[buffer(1)]], uint gl_InstanceIndex [[instance_id]])
{
main0_out out = {};
if (_12.mode == 0u)
{
out.f_color = in.v_color;
out.f_local_or_uv = in.v_uv;
out.f_params = float4(0.0);
out.f_params2 = float4(0.0);
out.f_flags = 0u;
out.f_uv_rect = float4(0.0);
out.f_effects = uint4(0u);
out.gl_Position = _12.projection * float4(in.v_position * _12.dpi_scale, 0.0, 1.0);
}
else
if (_12.mode == 1u)
{
Core_2D_Primitive p;
p.bounds = _75.primitives[int(gl_InstanceIndex)].bounds;
p.color = _75.primitives[int(gl_InstanceIndex)].color;
p.flags = _75.primitives[int(gl_InstanceIndex)].flags;
p.rotation_sc = _75.primitives[int(gl_InstanceIndex)].rotation_sc;
p._pad = _75.primitives[int(gl_InstanceIndex)]._pad;
p.params = _75.primitives[int(gl_InstanceIndex)].params;
p.params2 = _75.primitives[int(gl_InstanceIndex)].params2;
p.uv_rect = _75.primitives[int(gl_InstanceIndex)].uv_rect;
p.effects = _75.primitives[int(gl_InstanceIndex)].effects;
p.bounds = _31.primitives[int(gl_InstanceIndex)].bounds;
p.color = _31.primitives[int(gl_InstanceIndex)].color;
p.flags = _31.primitives[int(gl_InstanceIndex)].flags;
p.rotation_sc = _31.primitives[int(gl_InstanceIndex)].rotation_sc;
p._pad = _31.primitives[int(gl_InstanceIndex)]._pad;
p.params = _31.primitives[int(gl_InstanceIndex)].params;
p.params2 = _31.primitives[int(gl_InstanceIndex)].params2;
p.uv_rect = _31.primitives[int(gl_InstanceIndex)].uv_rect;
p.effects = _31.primitives[int(gl_InstanceIndex)].effects;
float2 corner = in.v_position;
float2 world_pos = mix(p.bounds.xy, p.bounds.zw, corner);
float2 center = (p.bounds.xy + p.bounds.zw) * 0.5;
@@ -105,6 +94,27 @@ vertex main0_out main0(main0_in in [[stage_in]], constant Uniforms& _12 [[buffer
out.f_effects = p.effects;
out.gl_Position = _12.projection * float4(world_pos * _12.dpi_scale, 0.0, 1.0);
}
else
{
out.f_color = in.v_color;
out.f_local_or_uv = in.v_uv;
out.f_params = float4(0.0);
out.f_params2 = float4(0.0);
out.f_flags = 0u;
out.f_uv_rect = float4(0.0);
out.f_effects = uint4(0u);
float2 _199;
if (_12.mode == 2u)
{
_199 = in.v_position;
}
else
{
_199 = in.v_position * _12.dpi_scale;
}
float2 pos = _199;
out.gl_Position = _12.projection * float4(pos, 0.0, 1.0);
}
return out;
}
Binary file not shown.
+6 -6
View File
@@ -40,9 +40,9 @@ const uint MAX_KERNEL_PAIRS = 32;
// --- Inputs from vertex shader ---
layout(location = 0) in vec2 p_local;
layout(location = 1) in mediump vec4 f_color;
layout(location = 2) flat in vec2 f_half_size;
layout(location = 3) flat in vec4 f_radii;
layout(location = 4) flat in float f_half_feather;
layout(location = 2) flat in vec2 f_half_size_ppx;
layout(location = 3) flat in vec4 f_radii_ppx;
layout(location = 4) flat in float f_half_feather_ppx;
// --- Output ---
layout(location = 0) out vec4 out_color;
@@ -123,15 +123,15 @@ void main() {
// ---- Mode 1: composite per-primitive.
// RRect SDF — early discard for fragments well outside the masked region.
float d = sdRoundedBox(p_local, f_half_size, f_radii);
if (d > f_half_feather) {
float d = sdRoundedBox(p_local, f_half_size_ppx, f_radii_ppx);
if (d > f_half_feather_ppx) {
discard;
}
// fwidth-based normalization for AA (matches main pipeline approach).
float grad_magnitude = max(fwidth(d), 1e-6);
float d_n = d / grad_magnitude;
float h_n = f_half_feather / grad_magnitude;
float h_n = f_half_feather_ppx / grad_magnitude;
// Sample the fully-blurred working-res texture. gl_FragCoord is full-res; convert to
// working-res UV via inv_downsample_factor. No kernel is applied — the H+V blur passes
+16 -16
View File
@@ -24,12 +24,12 @@
layout(location = 0) out vec2 p_local;
// f_color: tint, unpacked from primitive.color. Only meaningful in mode 1.
layout(location = 1) out mediump vec4 f_color;
// f_half_size: RRect half extents in physical pixels (mode 1 only).
layout(location = 2) flat out vec2 f_half_size;
// f_radii: per-corner radii in physical pixels (mode 1 only).
layout(location = 3) flat out vec4 f_radii;
// f_half_feather: SDF anti-aliasing feather (mode 1 only).
layout(location = 4) flat out float f_half_feather;
// f_half_size_ppx: RRect half extents in physical pixels (mode 1 only).
layout(location = 2) flat out vec2 f_half_size_ppx;
// f_radii_ppx: per-corner radii in physical pixels (mode 1 only).
layout(location = 3) flat out vec4 f_radii_ppx;
// f_half_feather_ppx: SDF anti-aliasing feather in physical pixels (mode 1 only).
layout(location = 4) flat out float f_half_feather_ppx;
// --- Uniforms (set 1) ---
// Backdrop pipeline's own uniform block — distinct from the main pipeline's
@@ -53,10 +53,10 @@ layout(set = 1, binding = 0) uniform Uniforms {
// edge effects (e.g. liquid-glass-style refraction outlines) would be a dedicated
// primitive type with its own pipeline rather than a flag bit here.
struct Gaussian_Blur_Primitive {
vec4 bounds; // 0-15: min_xy, max_xy (world-space)
vec4 radii; // 16-31: per-corner radii (physical px)
vec2 half_size; // 32-39: RRect half extents (physical px)
float half_feather; // 40-43: SDF anti-aliasing feather (physical px)
vec4 bounds; // 0-15: min_xy, max_xy (world-space, logical px)
vec4 radii_ppx; // 16-31: per-corner radii
vec2 half_size_ppx; // 32-39: RRect half extents
float half_feather_ppx; // 40-43: SDF anti-aliasing feather
uint color; // 44-47: tint, packed RGBA u8x4
};
@@ -78,9 +78,9 @@ void main() {
// Mode 0 doesn't read the per-primitive varyings; zero-init for safety.
p_local = vec2(0.0);
f_color = vec4(0.0);
f_half_size = vec2(0.0);
f_radii = vec4(0.0);
f_half_feather = 0.0;
f_half_size_ppx = vec2(0.0);
f_radii_ppx = vec4(0.0);
f_half_feather_ppx = 0.0;
} else {
// ---- Mode 1: V-composite instanced unit-quad over Gaussian_Blur_Primitive ----
Gaussian_Blur_Primitive p = primitives[gl_InstanceIndex];
@@ -101,9 +101,9 @@ void main() {
p_local = (world_pos - center) * dpi_scale;
f_color = unpackUnorm4x8(p.color);
f_half_size = p.half_size;
f_radii = p.radii;
f_half_feather = p.half_feather;
f_half_size_ppx = p.half_size_ppx;
f_radii_ppx = p.radii_ppx;
f_half_feather_ppx = p.half_feather_ppx;
gl_Position = projection * vec4(world_pos * dpi_scale, 0.0, 1.0);
}
+26 -26
View File
@@ -45,7 +45,7 @@ float sdRegularPolygon(vec2 p, float r, float n) {
return length(p) * cos(bn) - r;
}
// Coverage from SDF distance using half-feather width (feather_px * 0.5, pre-computed on CPU).
// Coverage from SDF distance using half-feather width (feather_ppx * 0.5, pre-computed on CPU).
// Produces a symmetric transition centered on d=0: smoothstep(-h, h, d).
float sdf_alpha(float d, float h) {
return 1.0 - smoothstep(-h, h, d);
@@ -80,56 +80,56 @@ void main() {
// SDF path — dispatch on kind
float d = 1e30;
float h = 0.5; // half-feather width; overwritten per shape kind
vec2 half_size = f_params.xy; // used by RRect and as reference size for gradients
float h = 0.5; // half-feather width (physical px); overwritten per shape kind
vec2 half_size_ppx = f_params.xy; // used by RRect and as reference size for gradients
vec2 p_local = f_local_or_uv; // arrives rotated; vertex shader handled .Rotated
vec2 p_local_ppx = f_local_or_uv; // arrives rotated; vertex shader handled .Rotated
if (kind == 1u) {
// RRect — half_feather in params2.z
vec4 corner_radii = vec4(f_params.zw, f_params2.xy);
// RRect — half_feather_ppx in params2.z
vec4 corner_radii_ppx = vec4(f_params.zw, f_params2.xy);
h = f_params2.z;
d = sdRoundedBox(p_local, half_size, corner_radii);
d = sdRoundedBox(p_local_ppx, half_size_ppx, corner_radii_ppx);
}
else if (kind == 2u) {
// NGon — half_feather in params.z
float radius = f_params.x;
// NGon — half_feather_ppx in params.z
float radius_ppx = f_params.x;
float sides = f_params.y;
h = f_params.z;
d = sdRegularPolygon(p_local, radius, sides);
half_size = vec2(radius); // for gradient UV computation
d = sdRegularPolygon(p_local_ppx, radius_ppx, sides);
half_size_ppx = vec2(radius_ppx); // for gradient UV computation
}
else if (kind == 3u) {
// Ellipse — half_feather in params.z
vec2 ab = f_params.xy;
// Ellipse — half_feather_ppx in params.z
vec2 radii_ppx = f_params.xy;
h = f_params.z;
d = sdEllipseApprox(p_local, ab);
half_size = ab; // for gradient UV computation
d = sdEllipseApprox(p_local_ppx, radii_ppx);
half_size_ppx = radii_ppx; // for gradient UV computation
}
else if (kind == 4u) {
// Ring_Arc — half_feather in params2.z
// Ring_Arc — half_feather_ppx in params2.z
// Arc mode from flag bits 5-6: 0 = full, 1 = narrow (≤π), 2 = wide (>π)
float inner = f_params.x;
float outer = f_params.y;
float inner_radius_ppx = f_params.x;
float outer_radius_ppx = f_params.y;
vec2 n_start = f_params.zw;
vec2 n_end = f_params2.xy;
uint arc_bits = (flags >> 5u) & 3u;
h = f_params2.z;
float r = length(p_local);
d = max(inner - r, r - outer);
float r = length(p_local_ppx);
d = max(inner_radius_ppx - r, r - outer_radius_ppx);
if (arc_bits != 0u) {
float d_start = dot(p_local, n_start);
float d_end = dot(p_local, n_end);
float d_start = dot(p_local_ppx, n_start);
float d_end = dot(p_local_ppx, n_end);
float d_wedge = (arc_bits == 1u)
? max(d_start, d_end) // arc ≤ π: intersect half-planes
: min(d_start, d_end); // arc > π: union half-planes
d = max(d, d_wedge);
}
half_size = vec2(outer); // for gradient UV computation
half_size_ppx = vec2(outer_radius_ppx); // for gradient UV computation
}
// --- fwidth-based normalization for correct AA and stroke width ---
@@ -146,18 +146,18 @@ void main() {
if ((flags & 4u) != 0u) {
// Radial gradient (bit 2): t from distance to center
mediump float t = length(p_local / half_size);
mediump float t = length(p_local_ppx / half_size_ppx);
shape_color = gradient_2color(gradient_start, gradient_end, t);
} else {
// Linear gradient: direction pre-computed on CPU as (cos, sin) f16 pair
vec2 direction = unpackHalf2x16(f_effects.z);
mediump float t = dot(p_local / half_size, direction) * 0.5 + 0.5;
mediump float t = dot(p_local_ppx / half_size_ppx, direction) * 0.5 + 0.5;
shape_color = gradient_2color(gradient_start, gradient_end, t);
}
} else if ((flags & 1u) != 0u) {
// Textured (bit 0)
vec4 uv_rect = f_uv_rect;
vec2 local_uv = p_local / half_size * 0.5 + 0.5;
vec2 local_uv = p_local_ppx / half_size_ppx * 0.5 + 0.5;
vec2 uv = mix(uv_rect.xy, uv_rect.zw, local_uv);
shape_color = f_color * texture(tex, uv);
} else {
+31 -14
View File
@@ -1,6 +1,6 @@
#version 450 core
// ---------- Vertex attributes (used in both modes) ----------
// ---------- Vertex attributes (used in all modes) ----------
layout(location = 0) in vec2 v_position;
layout(location = 1) in vec2 v_uv;
layout(location = 2) in vec4 v_color;
@@ -16,10 +16,18 @@ layout(location = 6) flat out vec4 f_uv_rect;
layout(location = 7) flat out uvec4 f_effects;
// ---------- Uniforms (single block — avoids spirv-cross reordering on Metal) ----------
// Mode values mirror Core_2D_Mode in core_2d.odin:
// 0 = Tessellated v_position is in logical pixels; shader scales by dpi_scale.
// 1 = SDF v_position is a unit-quad corner; world-space comes from
// primitives[gl_InstanceIndex].bounds (logical px). Shader
// scales by dpi_scale.
// 2 = Text v_position is in *physical* pixels already (the CPU baked
// the anchor snap and SDL_ttf glyph offsets, both physical).
// Shader must NOT rescale.
layout(set = 1, binding = 0) uniform Uniforms {
mat4 projection;
float dpi_scale;
uint mode; // 0 = tessellated, 1 = SDF
uint mode;
};
// ---------- SDF primitive storage buffer ----------
@@ -44,18 +52,7 @@ layout(std430, set = 0, binding = 0) readonly buffer Core_2D_Primitives {
// ---------- Entry point ----------
void main() {
if (mode == 0u) {
// ---- Mode 0: Tessellated (used for text and arbitrary user geometry) ----
f_color = v_color;
f_local_or_uv = v_uv;
f_params = vec4(0.0);
f_params2 = vec4(0.0);
f_flags = 0u;
f_uv_rect = vec4(0.0);
f_effects = uvec4(0);
gl_Position = projection * vec4(v_position * dpi_scale, 0.0, 1.0);
} else {
if (mode == 1u) {
// ---- Mode 1: SDF instanced quads ----
Core_2D_Primitive p = primitives[gl_InstanceIndex];
@@ -86,5 +83,25 @@ void main() {
f_effects = p.effects;
gl_Position = projection * vec4(world_pos * dpi_scale, 0.0, 1.0);
} else {
// ---- Mode 0 (Tessellated) and Mode 2 (Text) ----
// Both feed the raw-vertex pipeline (kind 0 in the fragment shader).
// They differ only in what coord space `v_position` is in:
// Mode 0 — logical pixels, scale here by dpi_scale.
// Mode 2 — physical pixels (CPU pre-scaled and snapped to integer
// physical pixels for atlas-aligned bilinear sampling).
// Do NOT rescale.
// `mode` is uniform across the workgroup, so the select compiles to a
// uniform-controlled branch with no SIMT divergence cost.
f_color = v_color;
f_local_or_uv = v_uv;
f_params = vec4(0.0);
f_params2 = vec4(0.0);
f_flags = 0u;
f_uv_rect = vec4(0.0);
f_effects = uvec4(0);
vec2 pos = (mode == 2u) ? v_position : (v_position * dpi_scale);
gl_Position = projection * vec4(pos, 0.0, 1.0);
}
}
+40 -10
View File
@@ -21,8 +21,8 @@ auto_segments :: proc(radius: f32, arc_degrees: f32) -> int {
// ----- Internal helpers -----
// Color is premultiplied: the tessellated fragment shader passes it through directly
// and the blend state is ONE, ONE_MINUS_SRC_ALPHA.
// Premultiplies the color before storing it on the vertex (see draw package doc's
// "Color and blending" section for why).
//INTERNAL
solid_vertex :: proc(position: draw.Vec2, color: draw.Color) -> draw.Vertex_2D {
return draw.Vertex_2D{position = position, color = draw.premultiply_color(color)}
@@ -108,16 +108,23 @@ triangle :: proc(
draw.prepare_shape(layer, vertices[:])
}
// Draw an anti-aliased triangle via extruded edge quads.
// Draw an anti-aliased triangle via extruded edge quads plus corner fan caps.
// Interior vertices get the full premultiplied color; outer fringe vertices get BLANK (0,0,0,0).
// The rasterizer linearly interpolates between them, producing a smooth 1-pixel AA band.
// `aa_px` controls the extrusion width in logical pixels (default 1.0).
// This proc emits 21 vertices (3 interior + 6 edge quads × 3 verts each).
// The rasterizer linearly interpolates between them, producing a smooth ~1-physical-pixel AA band.
// `aa_ppx` controls the extrusion width in *physical* pixels (default 1.0). The CPU divides by
// `dpi_scaling` here so the vertex stream stays in logical px; the mode-0 vertex shader scales
// back to physical at draw time. Net AA band is ~aa_ppx physical pixels regardless of DPI.
//
// Topology: 3 interior verts + 6 edge-quad triangles (×3 verts) + 3 corner-fan triangles (×3 verts)
// = 30 verts total. The corner fans plug the wedge gaps that would otherwise appear between
// adjacent edge fringes at each triangle vertex; without them, sharp corners show a small
// background-colored crescent. Apex vertex is full color, both fringe verts are BLANK, so the
// fan rasterizes as an alpha-falloff triangle that blends visually into the adjacent edge bands.
triangle_aa :: proc(
layer: ^draw.Layer,
v1, v2, v3: draw.Vec2,
color: draw.Color,
aa_px: f32 = draw.DFT_FEATHER_PX,
aa_ppx: f32 = draw.DFT_FEATHER_PPX,
origin: draw.Vec2 = {},
rotation: f32 = 0,
) {
@@ -164,7 +171,9 @@ triangle_aa :: proc(
normal_12 := edge_normal(p1, p2, centroid_x, centroid_y)
normal_20 := edge_normal(p2, p0, centroid_x, centroid_y)
extrude_distance := aa_px * draw.GLOB.dpi_scaling
// aa_ppx is in physical pixels; divide by dpi_scaling so the extrusion lives in logical-pixel
// space (the mode-0 vertex shader will scale back to physical at draw time).
extrude_distance := aa_ppx / draw.GLOB.dpi_scaling
// Outer fringe vertices: each edge vertex extruded outward
outer_0_01 := p0 + normal_01 * extrude_distance
@@ -178,8 +187,8 @@ triangle_aa :: proc(
// Outer fringe is BLANK = {0,0,0,0} which is already premul.
transparent := draw.BLANK
// 3 interior + 6 × 3 edge-quad = 21 vertices
vertices: [21]draw.Vertex_2D
// 3 interior + 6 edge-quad tris (×3 verts) + 3 corner-fan tris (×3 verts) = 30 vertices
vertices: [30]draw.Vertex_2D
// Interior triangle
vertices[0] = solid_vertex(p0, color)
@@ -210,6 +219,27 @@ triangle_aa :: proc(
vertices[19] = solid_vertex(outer_0_20, transparent)
vertices[20] = solid_vertex(outer_2_20, transparent)
// Corner fan caps: each fills the wedge gap between the two edge fringes meeting at a
// triangle vertex. Apex is full color; both fringe verts are BLANK, so the rasterizer
// produces a smooth alpha falloff across the wedge (matches the adjacent edge-band
// gradients at the shared edges, so the seams are invisible). Vertex order per fan:
// [apex, fringe-from-incoming-edge, fringe-from-outgoing-edge].
// Cap at p0 (between incoming edge p2→p0 and outgoing edge p0→p1)
vertices[21] = solid_vertex(p0, color)
vertices[22] = solid_vertex(outer_0_20, transparent)
vertices[23] = solid_vertex(outer_0_01, transparent)
// Cap at p1 (between incoming edge p0→p1 and outgoing edge p1→p2)
vertices[24] = solid_vertex(p1, color)
vertices[25] = solid_vertex(outer_1_01, transparent)
vertices[26] = solid_vertex(outer_1_12, transparent)
// Cap at p2 (between incoming edge p1→p2 and outgoing edge p2→p0)
vertices[27] = solid_vertex(p2, color)
vertices[28] = solid_vertex(outer_2_12, transparent)
vertices[29] = solid_vertex(outer_2_20, transparent)
draw.prepare_shape(layer, vertices[:])
}