diff --git a/draw/README.md b/draw/README.md index 8259996..b1fafd9 100644 --- a/draw/README.md +++ b/draw/README.md @@ -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 ~22–26 fp32 VGPRs (~16–22 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 1–2 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 1–2 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 2–4 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 1–4 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 1–4 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 4–6× 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). } diff --git a/draw/backdrop.odin b/draw/backdrop.odin index cb97933..5b65b18 100644 --- a/draw/backdrop.odin +++ b/draw/backdrop.odin @@ -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) } diff --git a/draw/core_2d.odin b/draw/core_2d.odin index 6f9b8ae..96b1fa0 100644 --- a/draw/core_2d.odin +++ b/draw/core_2d.odin @@ -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) } } diff --git a/draw/cybersteel/cybersteel.odin b/draw/cybersteel/cybersteel.odin index 7f23310..6d95201 100644 --- a/draw/cybersteel/cybersteel.odin +++ b/draw/cybersteel/cybersteel.odin @@ -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 diff --git a/draw/draw.odin b/draw/draw.odin index 8634f28..cab4a39 100644 --- a/draw/draw.odin +++ b/draw/draw.odin @@ -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, ) } diff --git a/draw/examples/hellope.odin b/draw/examples/hellope.odin index f497a9c..694ab2c 100644 --- a/draw/examples/hellope.odin +++ b/draw/examples/hellope.odin @@ -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 diff --git a/draw/shaders/generated/backdrop_blur.frag.metal b/draw/shaders/generated/backdrop_blur.frag.metal index dea97aa..26184ac 100644 --- a/draw/shaders/generated/backdrop_blur.frag.metal +++ b/draw/shaders/generated/backdrop_blur.frag.metal @@ -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)); diff --git a/draw/shaders/generated/backdrop_blur.frag.spv b/draw/shaders/generated/backdrop_blur.frag.spv index 42e8d7c..6551ca3 100644 Binary files a/draw/shaders/generated/backdrop_blur.frag.spv and b/draw/shaders/generated/backdrop_blur.frag.spv differ diff --git a/draw/shaders/generated/backdrop_blur.vert.metal b/draw/shaders/generated/backdrop_blur.vert.metal index 4bce4ce..ec03c4a 100644 --- a/draw/shaders/generated/backdrop_blur.vert.metal +++ b/draw/shaders/generated/backdrop_blur.vert.metal @@ -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; diff --git a/draw/shaders/generated/backdrop_blur.vert.spv b/draw/shaders/generated/backdrop_blur.vert.spv index 65522fb..3bc6c54 100644 Binary files a/draw/shaders/generated/backdrop_blur.vert.spv and b/draw/shaders/generated/backdrop_blur.vert.spv differ diff --git a/draw/shaders/generated/base_2d.frag.metal b/draw/shaders/generated/base_2d.frag.metal index c2052dd..4e27fc1 100644 --- a/draw/shaders/generated/base_2d.frag.metal +++ b/draw/shaders/generated/base_2d.frag.metal @@ -107,57 +107,57 @@ fragment main0_out main0(main0_in in [[stage_in]], texture2d 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 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 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 tex [[textur else { float2 direction = float2(as_type(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 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); } diff --git a/draw/shaders/generated/base_2d.frag.spv b/draw/shaders/generated/base_2d.frag.spv index 8e85e79..ccc5437 100644 Binary files a/draw/shaders/generated/base_2d.frag.spv and b/draw/shaders/generated/base_2d.frag.spv differ diff --git a/draw/shaders/generated/base_2d.vert.metal b/draw/shaders/generated/base_2d.vert.metal index 0f7c83b..3adb278 100644 --- a/draw/shaders/generated/base_2d.vert.metal +++ b/draw/shaders/generated/base_2d.vert.metal @@ -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; } diff --git a/draw/shaders/generated/base_2d.vert.spv b/draw/shaders/generated/base_2d.vert.spv index 2d18546..82ae556 100644 Binary files a/draw/shaders/generated/base_2d.vert.spv and b/draw/shaders/generated/base_2d.vert.spv differ diff --git a/draw/shaders/source/backdrop_blur.frag b/draw/shaders/source/backdrop_blur.frag index 7193a24..8045ddb 100644 --- a/draw/shaders/source/backdrop_blur.frag +++ b/draw/shaders/source/backdrop_blur.frag @@ -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 diff --git a/draw/shaders/source/backdrop_blur.vert b/draw/shaders/source/backdrop_blur.vert index 01d3c65..e68826f 100644 --- a/draw/shaders/source/backdrop_blur.vert +++ b/draw/shaders/source/backdrop_blur.vert @@ -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); } diff --git a/draw/shaders/source/base_2d.frag b/draw/shaders/source/base_2d.frag index 7f0ed6e..b6b179c 100644 --- a/draw/shaders/source/base_2d.frag +++ b/draw/shaders/source/base_2d.frag @@ -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 { diff --git a/draw/shaders/source/base_2d.vert b/draw/shaders/source/base_2d.vert index e259374..f4d2a13 100644 --- a/draw/shaders/source/base_2d.vert +++ b/draw/shaders/source/base_2d.vert @@ -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); } } diff --git a/draw/tess/tess.odin b/draw/tess/tess.odin index 10ec7f5..553a5d3 100644 --- a/draw/tess/tess.odin +++ b/draw/tess/tess.odin @@ -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[:]) }