diff --git a/DESIGN.md b/DESIGN.md index a89b017..d05a840 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -115,10 +115,16 @@ independently, so the unit of tessellation is the tile: - **Layout** runs per (tile × style-layer) on worker threads: decode → filter → evaluate data-driven properties → tessellate into *buckets*. - A bucket's vertices are **tile-local f32** (origin at tile corner); each - tile draws with its own tile→clip matrix (lookout's origin-relative rule: + A bucket's vertices are **tile-local f32** (origin at tile corner), so a + cached bucket is reusable at any camera (lookout's origin-relative rule: absolute world f32 quantizes visibly at depth; `overlay.zig` proved the - fix). + fix). At CONCATENATION the buckets are rebased onto one scene origin, and + the scene draws with one matrix, one draw per (tile × layer). The host + therefore picks the world copy at the antimeridian, per tile and per scene, + because a per-vertex choice splits a primitive lying across the seam + (`Camera.placeTileX`). A tile's own bounds also travel with its geometry, + because a merged scene has no per-tile draw state to clip against + (`scene.CLIP_NONE`). - **Fills and lines** draw straight from resident buckets — pan/zoom/rotate never re-tessellates; a zoom change is a matrix change until the tile set itself changes (then new tiles lay out async while old ones keep drawing — diff --git a/examples/macos/main.m b/examples/macos/main.m index 40e2bc8..ee23bea 100644 --- a/examples/macos/main.m +++ b/examples/macos/main.m @@ -823,6 +823,7 @@ int main(int argc, const char *argv[]) { if ((ev = getenv("CHARTTABLE_LON"))) v.lon = atof(ev); if ((ev = getenv("CHARTTABLE_LAT"))) v.lat = atof(ev); if ((ev = getenv("CHARTTABLE_ZOOM"))) v.zoom = atof(ev); + if ((ev = getenv("CHARTTABLE_BEARING"))) v.bearing_deg = atof(ev); BOOL composing = NO; #ifdef USE_TILE57_COMPOSE diff --git a/shaders/d3d12/README.md b/shaders/d3d12/README.md index d711237..e64cdce 100644 --- a/shaders/d3d12/README.md +++ b/shaders/d3d12/README.md @@ -54,7 +54,7 @@ Three root parameters and one static sampler, shared by all five pipelines: Both CBVs are root descriptors pointed at a per-draw slice of one upload-heap ring, so a draw costs one address write and no descriptor allocation. The block -is `scene.Uniforms`, 128 bytes; HLSL's constant-buffer packing lands every field +is `scene.Uniforms`, 144 bytes; HLSL's constant-buffer packing lands every field at the offset the struct declares. ## Conventions this backend does not have to correct for diff --git a/shaders/d3d12/fill.frag.hlsl b/shaders/d3d12/fill.frag.hlsl index 4079b1b..113ab34 100644 --- a/shaders/d3d12/fill.frag.hlsl +++ b/shaders/d3d12/fill.frag.hlsl @@ -1,11 +1,39 @@ // Flat color straight from stream B, blended in paint order by the // fixed-function blend state. +// b0 is visible to both stages (the root signature says ALL), so the pixel +// stage reads the same block the vertex stage did. +cbuffer U : register(b0) { + column_major float4x4 u_mvp; + float2 u_px_to_clip; + float u_size_scale; + float u_zoom; + float u_zoom_t; + float u_world_per_px; + float u_rot_sin; + float u_rot_cos; + float4 u_color; + float2 u_anchor_px; + float2 u_cell_px; + float4 u_clip_rect; +}; + +// A tile's triangles paint their own tile. The geometry keeps the buffered +// overhang a line's joins are built from, and the draw trims it +// (scene.CLIP_NONE). A draw with no tile of its own is set to CLIP_NONE and +// pays one compare. +bool clipped(float2 world) { + return world.x < u_clip_rect.x || world.y < u_clip_rect.y || + world.x > u_clip_rect.z || world.y > u_clip_rect.w; +} + struct PSIn { float4 pos : SV_Position; float4 color : TEXCOORD0; + float2 world : TEXCOORD1; }; float4 main(PSIn i) : SV_Target { + if (clipped(i.world)) discard; return i.color; } diff --git a/shaders/d3d12/fill.vert.hlsl b/shaders/d3d12/fill.vert.hlsl index 5e27e13..b05ee8a 100644 --- a/shaders/d3d12/fill.vert.hlsl +++ b/shaders/d3d12/fill.vert.hlsl @@ -11,12 +11,13 @@ cbuffer U : register(b0) { float u_size_scale; float u_zoom; // fractional zoom * 256 float u_zoom_t; - float u_wrap_x; + float u_world_per_px; float u_rot_sin; float u_rot_cos; float4 u_color; float2 u_anchor_px; float2 u_cell_px; + float4 u_clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; struct VSIn { @@ -35,15 +36,16 @@ struct VSIn { struct VSOut { float4 pos : SV_Position; float4 color : TEXCOORD0; + float2 world : TEXCOORD1; }; -// Longitude is cyclic: draw each vertex at the world instance nearest the -// camera, so a view straddling the antimeridian is seamless. The world period -// is exactly 1.0 in ANY translated frame, so this works on tile-local -// coordinates as long as u_wrap_x is stated in the same frame. +// Longitude is cyclic, so a tile has a world copy every 1.0 world units and +// one of them is drawn. The host picks it, once per tile (Camera.placeTileX) +// and once per scene in the matrix (Camera.mvpOrigin wraps its x delta). +// Picking it here, per vertex, split every primitive lying across the +// half-world seam. float4 project(float2 p) { - float2 world = float2(p.x + round(u_wrap_x - p.x), p.y); - return mul(u_mvp, float4(world, 0.0, 1.0)); + return mul(u_mvp, float4(p, 0.0, 1.0)); } // The per-vertex zoom visibility window, quantized to 1/256 zoom steps and @@ -57,6 +59,20 @@ bool gate(uint zwin) { // (ox, oy) is added AFTER projection, in reference px. map_align means it is // stated in the MAP frame: a rotated view must turn it, or the pen shears to // |cos(rotation)| of its width. +// The world position a vertex's fragments cover: the vertex position plus its +// screen-space (ox, oy) converted to world units. The offset is applied after +// projection, so a line's stroke reaches ground its anchor does not, and a +// clip test against the anchor alone cuts the stroke along its length. +// +// Undoing the projection needs no inverse. The linear part of mvp is scale +// times rotation, so R(-view) applied to the offset and divided by pixels per +// world unit gives the world delta of the clip-space offset. +float2 world_of(float2 p, float2 off) { + float2 back = float2( off.x * u_rot_cos + off.y * u_rot_sin, + -off.x * u_rot_sin + off.y * u_rot_cos); + return p + back * u_size_scale * u_world_per_px; +} + float2 screen_offset(float2 off, uint flags) { if ((flags & 1) != 0) { off = float2(off.x * u_rot_cos - off.y * u_rot_sin, @@ -82,5 +98,6 @@ VSOut main(VSIn i) { // Clamped: mid-gesture zoom_t can leave [0,1]; hold the end color // rather than wrapping to the far one. o.color = lerp(i.a_color, i.a_color_hi, saturate(u_zoom_t)); + o.world = world_of(i.a_pos, off); return o; } diff --git a/shaders/d3d12/overlay.vert.hlsl b/shaders/d3d12/overlay.vert.hlsl index a476cad..0cefce1 100644 --- a/shaders/d3d12/overlay.vert.hlsl +++ b/shaders/d3d12/overlay.vert.hlsl @@ -8,12 +8,13 @@ cbuffer U : register(b0) { float u_size_scale; float u_zoom; float u_zoom_t; - float u_wrap_x; + float u_world_per_px; float u_rot_sin; float u_rot_cos; float4 u_color; float2 u_anchor_px; float2 u_cell_px; + float4 u_clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; struct VSIn { @@ -28,10 +29,10 @@ struct VSOut { VSOut main(VSIn i) { VSOut o; - // The chart shader's antimeridian wrap: draw at the world instance nearest - // the camera. A whole world width is 1.0 in the relative frame too. - float2 world = float2(i.a_world.x + round(u_wrap_x - i.a_world.x), i.a_world.y); - float4 clip = mul(u_mvp, float4(world, 0.0, 1.0)); + // The host places the whole frame at once, in the matrix + // (Camera.mvpOrigin). A per-vertex wrap split any primitive lying across + // the half-world seam. + float4 clip = mul(u_mvp, float4(i.a_world, 0.0, 1.0)); // z = 0 is the near plane. The chart's paint-order depths are all in (0,1) // and this pass writes no depth, so host content is never hidden by the // chart and never hides it from a later pass. diff --git a/shaders/d3d12/pattern.frag.hlsl b/shaders/d3d12/pattern.frag.hlsl index a031779..cb51c24 100644 --- a/shaders/d3d12/pattern.frag.hlsl +++ b/shaders/d3d12/pattern.frag.hlsl @@ -7,13 +7,41 @@ Texture2D cell_tex : register(t0); SamplerState cell_smp : register(s0); +// b0 is visible to both stages (the root signature says ALL), so the pixel +// stage reads the same block the vertex stage did. +cbuffer U : register(b0) { + column_major float4x4 u_mvp; + float2 u_px_to_clip; + float u_size_scale; + float u_zoom; + float u_zoom_t; + float u_world_per_px; + float u_rot_sin; + float u_rot_cos; + float4 u_color; + float2 u_anchor_px; + float2 u_cell_px; + float4 u_clip_rect; +}; + +// A tile's triangles paint their own tile. The geometry keeps the buffered +// overhang a line's joins are built from, and the draw trims it +// (scene.CLIP_NONE). A draw with no tile of its own is set to CLIP_NONE and +// pays one compare. +bool clipped(float2 world) { + return world.x < u_clip_rect.x || world.y < u_clip_rect.y || + world.x > u_clip_rect.z || world.y > u_clip_rect.w; +} + struct PSIn { float4 pos : SV_Position; float2 anchor : TEXCOORD0; float2 cell : TEXCOORD1; + float2 world : TEXCOORD2; }; float4 main(PSIn i) : SV_Target { + if (clipped(i.world)) discard; float2 sz = max(i.cell, float2(1.0, 1.0)); float2 uv = frac((i.pos.xy - i.anchor) / sz); float4 c = cell_tex.Sample(cell_smp, uv); diff --git a/shaders/d3d12/pattern.vert.hlsl b/shaders/d3d12/pattern.vert.hlsl index 604b983..e88cedf 100644 --- a/shaders/d3d12/pattern.vert.hlsl +++ b/shaders/d3d12/pattern.vert.hlsl @@ -10,12 +10,13 @@ cbuffer U : register(b0) { float u_size_scale; float u_zoom; float u_zoom_t; - float u_wrap_x; + float u_world_per_px; float u_rot_sin; float u_rot_cos; float4 u_color; float2 u_anchor_px; float2 u_cell_px; + float4 u_clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; struct VSIn { @@ -30,12 +31,19 @@ struct VSOut { float4 pos : SV_Position; float2 anchor : TEXCOORD0; float2 cell : TEXCOORD1; + float2 world : TEXCOORD2; }; +// See fill.vert.hlsl: the world position a vertex's fragments actually land on. +float2 world_of(float2 p, float2 off) { + float2 back = float2( off.x * u_rot_cos + off.y * u_rot_sin, + -off.x * u_rot_sin + off.y * u_rot_cos); + return p + back * u_size_scale * u_world_per_px; +} + VSOut main(VSIn i) { VSOut o; - float2 world = float2(i.a_pos.x + round(u_wrap_x - i.a_pos.x), i.a_pos.y); - float4 clip = mul(u_mvp, float4(world, 0.0, 1.0)); + float4 clip = mul(u_mvp, float4(i.a_pos, 0.0, 1.0)); float2 off = i.a_off; if ((i.a_flags & 1) != 0) { off = float2(off.x * u_rot_cos - off.y * u_rot_sin, @@ -50,5 +58,6 @@ VSOut main(VSIn i) { o.pos = vis ? clip : float4(0.0, 0.0, 2.0, 1.0); o.anchor = u_anchor_px; o.cell = u_cell_px; + o.world = world_of(i.a_pos, off); return o; } diff --git a/shaders/d3d12/sdf.frag.hlsl b/shaders/d3d12/sdf.frag.hlsl index bdfa9d0..7bd022f 100644 --- a/shaders/d3d12/sdf.frag.hlsl +++ b/shaders/d3d12/sdf.frag.hlsl @@ -18,12 +18,13 @@ cbuffer U : register(b1) { float u_size_scale; float u_zoom; float u_zoom_t; - float u_wrap_x; + float u_world_per_px; float u_rot_sin; float u_rot_cos; float4 u_color; // the SDF halo color for this draw float2 u_anchor_px; float2 u_cell_px; + float4 u_clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; struct PSIn { diff --git a/shaders/d3d12/sprite.vert.hlsl b/shaders/d3d12/sprite.vert.hlsl index 332b43d..a48551a 100644 --- a/shaders/d3d12/sprite.vert.hlsl +++ b/shaders/d3d12/sprite.vert.hlsl @@ -12,12 +12,13 @@ cbuffer U : register(b0) { float u_size_scale; float u_zoom; float u_zoom_t; - float u_wrap_x; + float u_world_per_px; float u_rot_sin; float u_rot_cos; float4 u_color; float2 u_anchor_px; float2 u_cell_px; + float4 u_clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; struct VSIn { @@ -45,8 +46,7 @@ VSOut main(VSIn i) { bool flip = ((i.a_pack >> 8) & 0xFF) != 0; float tangent = float((i.a_pack >> 16) & 0xFF) / 256.0 * 6.2831853071795864; - float2 world = float2(i.a_pos.x + round(u_wrap_x - i.a_pos.x), i.a_pos.y); - float4 clip = mul(u_mvp, float4(world, 0.0, 1.0)); + float4 clip = mul(u_mvp, float4(i.a_pos, 0.0, 1.0)); float2 off = i.a_off; // Keep a tangent-rotated run (line-following text) upright: if the run, diff --git a/shaders/metal.metal b/shaders/metal.metal index 3eb45bf..0d9e380 100644 --- a/shaders/metal.metal +++ b/shaders/metal.metal @@ -25,14 +25,15 @@ struct U { float size_scale; // pixel density x symbol size multiplier float zoom; // fractional zoom * 256, tested against zmin/zmax float zoom_t; // fract(zoom): mix factor for zoom-interpolated paint - float wrap_x; // camera centre x IN THE VERTEX FRAME (antimeridian) + float world_per_px; // world units per reference px (offset -> world) float rot_sin; float rot_cos; float4 color; // SDF halo background; SDF fragment stage only float2 anchor_px; // pattern phase origin, framebuffer px float2 cell_px; // pattern cell period, framebuffer px + float4 clip_rect; // the tile a triangle draw may paint: x0, y0, x1, y1 }; -static_assert(sizeof(U) == 128, "U must match scene.Uniforms (128 B)"); +static_assert(sizeof(U) == 144, "U must match scene.Uniforms (144 B)"); // ---- vertex streams (== scene/types.zig structs) ---------------------------- struct Vertex { // scene.Vertex, 28 B @@ -72,14 +73,15 @@ static_assert(sizeof(Quad) == 40, "Quad must match scene.Quad (40 B)"); constant uint FLAG_MAP_ALIGN = 1u; -// Longitude is cyclic: draw each vertex at the world instance nearest the -// camera (x, x-1 or x+1), so a view straddling the antimeridian is seamless. -// The world period is exactly 1.0 world unit in ANY translated frame, so this -// works on tile-local coordinates as long as u.wrap_x is stated in the same -// frame (host: camera.center.x - tile_origin.x). +// Longitude is cyclic, so a tile has a world copy every 1.0 world units and +// one of them is drawn. The host picks it, once per tile (Camera.placeTileX) +// and once per scene in the matrix (Camera.mvpOrigin wraps its x delta). +// Picking it here, per vertex, split every primitive lying across the +// half-world seam: the corners on one side moved a whole world and the +// corners on the other did not, so a coastline drew as a band stretched +// across the map. static inline float4 project(constant U &u, float2 p) { - float2 world = float2(p.x + rint(u.wrap_x - p.x), p.y); - return u.mvp * float4(world, 0.0, 1.0); + return u.mvp * float4(p, 0.0, 1.0); } // The per-vertex zoom visibility window: zmin/zmax quantized to 1/256 zoom @@ -106,6 +108,7 @@ static inline float2 screen_offset(constant U &u, float2 off, uchar flags) { struct FillOut { float4 pos [[position]]; float4 color; + float2 world; }; // The zoom-interpolated pair: buffer(3) is the same property one integer @@ -121,6 +124,29 @@ static inline float4 paint_of(const device Paint *lo, const device Paint *hi, return mix(a, b, clamp(u.zoom_t, 0.0, 1.0)); } +// The world position a vertex's fragments cover: the vertex position plus its +// screen-space (ox, oy) converted to world units. The offset is applied after +// projection, so a line's stroke reaches ground its anchor does not, and a +// clip test against the anchor alone cuts the stroke along its length. +// +// Undoing the projection needs no inverse. The linear part of mvp is scale +// times rotation, so R(-view) applied to the offset and divided by pixels per +// world unit gives the world delta of the clip-space offset. +static inline float2 world_of(constant U &u, float2 p, float2 off) { + float2 back = float2(off.x * u.rot_cos + off.y * u.rot_sin, + -off.x * u.rot_sin + off.y * u.rot_cos); + return p + back * u.size_scale * u.world_per_px; +} + +// A tile's triangles paint their own tile. The geometry keeps the buffered +// overhang a line's joins are built from, and the draw trims it +// (scene.CLIP_NONE). A draw with no tile of its own is set to CLIP_NONE and +// pays one compare. +static inline bool clipped(constant U &u, float2 world) { + return world.x < u.clip_rect.x || world.y < u.clip_rect.y || + world.x > u.clip_rect.z || world.y > u.clip_rect.w; +} + vertex FillOut fill_vert(uint vid [[vertex_id]], const device Vertex *verts [[buffer(0)]], const device Paint *paint [[buffer(1)]], @@ -140,10 +166,12 @@ vertex FillOut fill_vert(uint vid [[vertex_id]], FillOut out; out.pos = gate(u, v.zmin, v.zmax) ? clip : float4(0.0, 0.0, 2.0, 1.0); // z=2 -> clipped out.color = paint_of(paint, paint_hi, vid, u); + out.world = world_of(u, v.pos, off); return out; } -fragment float4 fill_frag(FillOut in [[stage_in]]) { +fragment float4 fill_frag(FillOut in [[stage_in]], constant U &u [[buffer(1)]]) { + if (clipped(u, in.world)) discard_fragment(); return in.color; } @@ -155,6 +183,7 @@ struct PatternOut { float4 pos [[position]]; float2 anchor; float2 cell; + float2 world; }; vertex PatternOut pattern_vert(uint vid [[vertex_id]], @@ -169,6 +198,7 @@ vertex PatternOut pattern_vert(uint vid [[vertex_id]], out.pos = gate(u, v.zmin, v.zmax) ? clip : float4(0.0, 0.0, 2.0, 1.0); out.anchor = u.anchor_px; out.cell = u.cell_px; + out.world = world_of(u, v.pos, off); return out; } @@ -176,7 +206,9 @@ vertex PatternOut pattern_vert(uint vid [[vertex_id]], // both by the same amount, so the pattern is fixed to the map, not the screen. fragment float4 pattern_frag(PatternOut in [[stage_in]], texture2d cell [[texture(0)]], - sampler smp [[sampler(0)]]) { + sampler smp [[sampler(0)]], + constant U &u [[buffer(1)]]) { + if (clipped(u, in.world)) discard_fragment(); float2 sz = max(in.cell, float2(1.0)); float2 uv = fract((in.pos.xy - in.anchor) / sz); float4 c = cell.sample(smp, uv); @@ -276,7 +308,7 @@ fragment float4 sdf_frag(QuadOut in [[stage_in]], // // The stream is world-space positions relative to the frame's own origin, with // a colour per vertex. The host supplies the matching uniform (an mvp built -// for that origin), so this shader reads only mvp and wrap_x. +// for that origin), so this shader reads only mvp. struct OverlayVertex { // scene.OverlayVertex, 24 B // packed_float2/4 hold the stride at 24; natural alignment would pad to 32 // and shear the stream. @@ -294,10 +326,9 @@ vertex OverlayOut overlay_vert(uint vid [[vertex_id]], const device OverlayVertex *verts [[buffer(0)]], constant U &u [[buffer(2)]]) { OverlayVertex v = verts[vid]; - // The same antimeridian wrap the scene shaders apply: draw at the world - // instance nearest the camera, so an overlay across the seam is seamless. - float2 world = float2(v.world.x + rint(u.wrap_x - v.world.x), v.world.y); - float4 clip = u.mvp * float4(world, 0.0, 1.0); + // The host places the whole frame at once, in the matrix + // (Camera.mvpOrigin). See project() above. + float4 clip = u.mvp * float4(v.world, 0.0, 1.0); // z = 0 is the near plane. Every paint-order depth the scene writes is in // (0,1), so a depth-test-only overlay pass is never hidden by the map it // annotates — and it writes no depth, so it cannot hide the map either. diff --git a/shaders/vk/README.md b/shaders/vk/README.md index c9fcc5f..53bf703 100644 --- a/shaders/vk/README.md +++ b/shaders/vk/README.md @@ -51,7 +51,9 @@ are not negotiable — they are the struct layouts in scene/types.zig. lookout's SDL_GPU convention, kept so a port lands on familiar ground: set 1 binding 0 = the vertex-stage uniform block, set 2 binding 0 = the fragment sampler, set 3 binding 0 = the fragment-stage uniform block. The -block is `scene.Uniforms`, 128 bytes, std140-compatible in this field order. +block is `scene.Uniforms`, 144 bytes, std140-compatible in this field order. +The fill and pattern FRAGMENT stages read it from set 3 as well as sdf: every +triangle draw is clipped to `clip_rect`. ## Two contract changes from lookout, called out diff --git a/shaders/vk/fill.frag b/shaders/vk/fill.frag index e2a8091..c15a867 100644 --- a/shaders/vk/fill.frag +++ b/shaders/vk/fill.frag @@ -2,5 +2,34 @@ // Flat color straight from stream B, blended in paint order by the // fixed-function blend state. Matches metal.metal fill_frag. layout(location = 0) in vec4 v_color; +layout(location = 1) in vec2 v_world; layout(location = 0) out vec4 o_color; -void main() { o_color = v_color; } + +layout(set = 3, binding = 0) uniform U { + mat4 mvp; + vec2 px_to_clip; + float size_scale; + float zoom; + float zoom_t; + float world_per_px; + float rot_sin; + float rot_cos; + vec4 color; + vec2 anchor_px; + vec2 cell_px; + vec4 clip_rect; +} u; + +// A tile's triangles paint their own tile. The geometry keeps the buffered +// overhang a line's joins are built from, and the draw trims it +// (scene.CLIP_NONE). A draw with no tile of its own is set to CLIP_NONE and +// pays one compare. +bool clipped(vec2 world) { + return world.x < u.clip_rect.x || world.y < u.clip_rect.y || + world.x > u.clip_rect.z || world.y > u.clip_rect.w; +} + +void main() { + if (clipped(v_world)) discard; + o_color = v_color; +} diff --git a/shaders/vk/fill.frag.spv b/shaders/vk/fill.frag.spv index ac9d2fc..4d7f183 100644 Binary files a/shaders/vk/fill.frag.spv and b/shaders/vk/fill.frag.spv differ diff --git a/shaders/vk/fill.vert b/shaders/vk/fill.vert index 4922a19..f0b89c5 100644 --- a/shaders/vk/fill.vert +++ b/shaders/vk/fill.vert @@ -24,23 +24,39 @@ layout(set = 1, binding = 0) uniform U { float size_scale; float zoom; // fractional zoom * 256 float zoom_t; - float wrap_x; + float world_per_px; float rot_sin; float rot_cos; vec4 color; vec2 anchor_px; vec2 cell_px; + vec4 clip_rect; } u; layout(location = 0) out vec4 v_color; +layout(location = 1) out vec2 v_world; -// Longitude is cyclic: draw each vertex at the world instance nearest the -// camera, so a view straddling the antimeridian is seamless. The world period -// is exactly 1.0 in ANY translated frame, so this works on tile-local -// coordinates as long as u.wrap_x is stated in the same frame. +// Longitude is cyclic, so a tile has a world copy every 1.0 world units and +// one of them is drawn. The host picks it, once per tile (Camera.placeTileX) +// and once per scene in the matrix (Camera.mvpOrigin wraps its x delta). +// Picking it here, per vertex, split every primitive lying across the +// half-world seam. vec4 project(vec2 p) { - vec2 world = vec2(p.x + round(u.wrap_x - p.x), p.y); - return u.mvp * vec4(world, 0.0, 1.0); + return u.mvp * vec4(p, 0.0, 1.0); +} + +// The world position a vertex's fragments cover: the vertex position plus its +// screen-space (ox, oy) converted to world units. The offset is applied after +// projection, so a line's stroke reaches ground its anchor does not, and a +// clip test against the anchor alone cuts the stroke along its length. +// +// Undoing the projection needs no inverse. The linear part of mvp is scale +// times rotation, so R(-view) applied to the offset and divided by pixels per +// world unit gives the world delta of the clip-space offset. +vec2 world_of(vec2 p, vec2 off) { + vec2 back = vec2( off.x * u.rot_cos + off.y * u.rot_sin, + -off.x * u.rot_sin + off.y * u.rot_cos); + return p + back * u.size_scale * u.world_per_px; } // The per-vertex zoom visibility window, quantized to 1/256 zoom steps and @@ -78,4 +94,5 @@ void main() { // Clamped: mid-gesture zoom_t can leave [0,1]; hold the end color // rather than wrapping to the far one. v_color = mix(a_color, a_color_hi, clamp(u.zoom_t, 0.0, 1.0)); + v_world = world_of(a_pos, off); } diff --git a/shaders/vk/fill.vert.spv b/shaders/vk/fill.vert.spv index 984df9e..f3d08c2 100644 Binary files a/shaders/vk/fill.vert.spv and b/shaders/vk/fill.vert.spv differ diff --git a/shaders/vk/overlay.vert b/shaders/vk/overlay.vert index 03a3986..9e245a9 100644 --- a/shaders/vk/overlay.vert +++ b/shaders/vk/overlay.vert @@ -21,21 +21,22 @@ layout(set = 1, binding = 0) uniform U { float size_scale; float current_scale; uint cat_mask; - float wrap_x; + float world_per_px; float rot_sin; float rot_cos; vec4 color; vec2 anchor_px; vec2 cell_px; + vec4 clip_rect; } u; layout(location = 0) out vec4 v_color; void main() { - // The chart shader's antimeridian wrap: draw at the world instance nearest - // the camera. A whole world width is 1.0 in the relative frame too. - vec2 world = vec2(a_world.x + round(u.wrap_x - a_world.x), a_world.y); - vec4 clip = u.mvp * vec4(world, 0.0, 1.0); + // The host places the whole frame at once, in the matrix + // (Camera.mvpOrigin). A per-vertex wrap split any primitive lying across + // the half-world seam. + vec4 clip = u.mvp * vec4(a_world, 0.0, 1.0); // z = 0 is the near plane. The chart's paint-order depths are all in (0,1) // and this pass writes no depth, so plugin content is never hidden by the // chart and never hides it from a later pass. diff --git a/shaders/vk/overlay.vert.spv b/shaders/vk/overlay.vert.spv index 857bd55..7d4b387 100644 Binary files a/shaders/vk/overlay.vert.spv and b/shaders/vk/overlay.vert.spv differ diff --git a/shaders/vk/pattern.frag b/shaders/vk/pattern.frag index 593a56d..bbf9771 100644 --- a/shaders/vk/pattern.frag +++ b/shaders/vk/pattern.frag @@ -6,9 +6,32 @@ layout(set = 2, binding = 0) uniform sampler2D cell; layout(location = 0) in vec2 v_anchor; layout(location = 1) in vec2 v_cell; +layout(location = 2) in vec2 v_world; layout(location = 0) out vec4 o_color; +layout(set = 3, binding = 0) uniform U { + mat4 mvp; + vec2 px_to_clip; + float size_scale; + float zoom; + float zoom_t; + float world_per_px; + float rot_sin; + float rot_cos; + vec4 color; + vec2 anchor_px; + vec2 cell_px; + vec4 clip_rect; +} u; + +// See fill.frag: a tile's triangles paint their own tile and no more. +bool clipped(vec2 world) { + return world.x < u.clip_rect.x || world.y < u.clip_rect.y || + world.x > u.clip_rect.z || world.y > u.clip_rect.w; +} + void main() { + if (clipped(v_world)) discard; vec2 sz = max(v_cell, vec2(1.0)); vec2 uv = fract((gl_FragCoord.xy - v_anchor) / sz); vec4 c = texture(cell, uv); diff --git a/shaders/vk/pattern.frag.spv b/shaders/vk/pattern.frag.spv index 9b163dc..f3525c4 100644 Binary files a/shaders/vk/pattern.frag.spv and b/shaders/vk/pattern.frag.spv differ diff --git a/shaders/vk/pattern.vert b/shaders/vk/pattern.vert index b0d8af7..4a19319 100644 --- a/shaders/vk/pattern.vert +++ b/shaders/vk/pattern.vert @@ -17,20 +17,28 @@ layout(set = 1, binding = 0) uniform U { float size_scale; float zoom; float zoom_t; - float wrap_x; + float world_per_px; float rot_sin; float rot_cos; vec4 color; vec2 anchor_px; vec2 cell_px; + vec4 clip_rect; } u; layout(location = 0) out vec2 v_anchor; layout(location = 1) out vec2 v_cell; +layout(location = 2) out vec2 v_world; + +// See fill.vert: the world position a vertex's fragments actually land on. +vec2 world_of(vec2 p, vec2 off) { + vec2 back = vec2( off.x * u.rot_cos + off.y * u.rot_sin, + -off.x * u.rot_sin + off.y * u.rot_cos); + return p + back * u.size_scale * u.world_per_px; +} void main() { - vec2 world = vec2(a_pos.x + round(u.wrap_x - a_pos.x), a_pos.y); - vec4 clip = u.mvp * vec4(world, 0.0, 1.0); + vec4 clip = u.mvp * vec4(a_pos, 0.0, 1.0); vec2 off = a_off; if ((a_flags & 1u) != 0u) { off = vec2(off.x * u.rot_cos - off.y * u.rot_sin, @@ -45,4 +53,5 @@ void main() { gl_Position = vis ? clip : vec4(0.0, 0.0, 2.0, 1.0); v_anchor = u.anchor_px; v_cell = u.cell_px; + v_world = world_of(a_pos, off); } diff --git a/shaders/vk/pattern.vert.spv b/shaders/vk/pattern.vert.spv index b805fd2..164ef0d 100644 Binary files a/shaders/vk/pattern.vert.spv and b/shaders/vk/pattern.vert.spv differ diff --git a/shaders/vk/sdf.frag b/shaders/vk/sdf.frag index 398e635..7e65d64 100644 --- a/shaders/vk/sdf.frag +++ b/shaders/vk/sdf.frag @@ -16,12 +16,13 @@ layout(set = 3, binding = 0) uniform U { float size_scale; float zoom; float zoom_t; - float wrap_x; + float world_per_px; float rot_sin; float rot_cos; vec4 color; // the SDF halo color for this draw vec2 anchor_px; vec2 cell_px; + vec4 clip_rect; } u; layout(location = 0) in vec2 v_uv; diff --git a/shaders/vk/sdf.frag.spv b/shaders/vk/sdf.frag.spv index 61de35c..05d3473 100644 Binary files a/shaders/vk/sdf.frag.spv and b/shaders/vk/sdf.frag.spv differ diff --git a/shaders/vk/sprite.vert b/shaders/vk/sprite.vert index ef93742..3a01ffd 100644 --- a/shaders/vk/sprite.vert +++ b/shaders/vk/sprite.vert @@ -24,12 +24,13 @@ layout(set = 1, binding = 0) uniform U { float size_scale; float zoom; float zoom_t; - float wrap_x; + float world_per_px; float rot_sin; float rot_cos; vec4 color; vec2 anchor_px; vec2 cell_px; + vec4 clip_rect; } u; layout(location = 0) out vec2 v_uv; @@ -41,8 +42,7 @@ void main() { bool flip = ((a_pack >> 8) & 0xFFu) != 0u; float tangent = float((a_pack >> 16) & 0xFFu) / 256.0 * 6.2831853071795864; - vec2 world = vec2(a_pos.x + round(u.wrap_x - a_pos.x), a_pos.y); - vec4 clip = u.mvp * vec4(world, 0.0, 1.0); + vec4 clip = u.mvp * vec4(a_pos, 0.0, 1.0); vec2 off = a_off; // Keep a tangent-rotated run (line-following text) upright: if the run, diff --git a/shaders/vk/sprite.vert.spv b/shaders/vk/sprite.vert.spv index fb01758..b2557ff 100644 Binary files a/shaders/vk/sprite.vert.spv and b/shaders/vk/sprite.vert.spv differ diff --git a/src/camera.zig b/src/camera.zig index 2a1cdc4..3751b47 100644 --- a/src/camera.zig +++ b/src/camera.zig @@ -23,6 +23,38 @@ pub fn wrapDx(a: f64, b: f64) f64 { return d - std.math.round(d); } +/// The x of a tile's left edge in a frame with origin `origin_x`, at the +/// world copy nearest that origin. +/// +/// Longitude is cyclic, so a tile has a copy every 1.0 world units and the +/// renderer picks one for the whole tile. Nearest is measured at the tile's +/// CENTRE. Measured at the left edge, a tile one span wide counts as near +/// when only its far corner is near, and the rest of it draws a world away +/// from the view. +pub fn placeTileX(x0: f64, span: f64, origin_x: f64) f64 { + return wrapDx(x0 + span * 0.5, origin_x) - span * 0.5; +} + +/// The second world copy of a tile the view can see, as the offset to add to +/// its placement (-1 or +1), or null when one copy covers the view. +/// +/// `dx` is the placement placeTileX returned and `span` the tile's width, +/// both in world units in a frame centred on the view. `half_w` is the view's +/// half width there. A tile recurs every 1.0 world units, so a view wider +/// than the gap between two copies shows the same tile on both sides and has +/// to draw it on both. That starts around z1, where a tile is a large +/// fraction of the world. One placement per tile leaves a wedge of empty +/// ocean at one edge. +/// +/// At most one copy qualifies, because a tile is at most one world wide and +/// its copies are at least that far apart. +pub fn wrappedCopy(dx: f64, span: f64, half_w: f64) ?f64 { + for ([2]f64{ -1, 1 }) |k| { + if (dx + k < half_w and dx + k + span > -half_w) return k; + } + return null; +} + /// lon/lat (degrees) -> normalized web-mercator [0,1], y down. pub fn lonLatToWorld(lon: f64, lat: f64) Vec2 { const x = wrapX((lon + 180.0) / 360.0); @@ -90,7 +122,12 @@ pub const Camera = struct { const b: f64 = 2.0 * s / @as(f64, self.vh); const c = std.math.cos(self.rotation); const sn = std.math.sin(self.rotation); - const dx = origin.x - self.center.x; // added before rotate/scale + // Short way in x. The camera and a scene origin are both world x in + // [0,1), so a camera that crossed the antimeridian since the scene + // was built is a whole world away by subtraction and adjacent by + // longitude. Wrapping here moves the whole scene at once. A + // per-vertex wrap splits any primitive lying across the seam. + const dx = wrapDx(origin.x, self.center.x); // added before rotate/scale const dy = origin.y - self.center.y; var m = [_]f32{0} ** 16; m[0] = @floatCast(a * c); @@ -361,3 +398,59 @@ test "halfExtents holds the corners of a rotated viewport" { } } } + +// A wide view rendered as horizontal bands stretched across the map: the +// vertex stages picked a world copy per vertex, so a triangle lying across +// the half-world seam had corners a whole world apart. The host picks it now, +// in these two functions. +test "placeTileX puts a whole tile on its nearest copy" { + const std_testing = std.testing; + // z2: four columns, a quarter of the world each. + const span = 0.25; + inline for (.{ 0.0, 0.1234, 0.2361, 0.5, 0.75, 0.9999 }) |origin_x| { + var col: usize = 0; + while (col < 4) : (col += 1) { + const x0 = @as(f64, @floatFromInt(col)) * span; + const dx = placeTileX(x0, span, origin_x); + // The same geography: a whole number of worlds from where the + // tile is. + const worlds = dx - (x0 - origin_x); + try std_testing.expectApproxEqAbs(@round(worlds), worlds, 1e-12); + // The nearest copy, measured at the tile's centre. Measured at + // the left edge, one corner of the tile decides the placement. + try std_testing.expect(@abs(dx + span * 0.5) <= 0.5); + } + } +} + +test "wrappedCopy asks for the second copy only when the view can see it" { + const std_testing = std.testing; + // z1: two columns, half the world each, and a view of the whole world. + // The column placed to the west is visible in the east as well. Without + // a second placement that side is empty ocean. + try std_testing.expectEqual(@as(?f64, 1), wrappedCopy(-0.7361, 0.5, 0.5)); + // The same tile under a view half that wide is off screen on both sides. + try std_testing.expect(wrappedCopy(-0.7361, 0.5, 0.25) == null); + // A tile under the camera needs no second copy. Its other copies are a + // world away, and no view is that wide. + try std_testing.expect(wrappedCopy(-0.1, 0.25, 0.5) == null); +} + +// mvpOrigin wraps its x delta. Without that, a camera that crossed the +// antimeridian since the scene was built is a whole world from its origin by +// subtraction, and the scene translates off screen. +test "mvpOrigin turns the whole scene at the antimeridian" { + const std_testing = std.testing; + const cam = Camera{ + .origin = .{ .x = 0.99, .y = 0.5 }, + .center = .{ .x = 0.01, .y = 0.5 }, + .zoom = 4, + .vw = 1024, + .vh = 768, + }; + const near = cam.mvpOrigin(cam.origin); + // 0.99 is 0.02 west of 0.01. The translation is the small one, with the + // sign that places the origin left of centre. + const a = 2.0 * cam.worldToPx() / @as(f64, cam.vw); + try std_testing.expectApproxEqAbs(@as(f32, @floatCast(a * -0.02)), near[12], 1e-3); +} diff --git a/src/gpu/gpu_d3d12.zig b/src/gpu/gpu_d3d12.zig index dda62f6..42ca595 100644 --- a/src/gpu/gpu_d3d12.zig +++ b/src/gpu/gpu_d3d12.zig @@ -288,7 +288,7 @@ pub const Gpu = struct { overlay_buf: Buffer = .{}, overlay_count: u32 = 0, overlay_gen: u64 = 0, // 0 = nothing uploaded yet (a built store is >= 1) - /// The overlay pass's own frame uniform: the chart's, with the MVP and wrap + /// The overlay pass's own frame uniform: the chart's, with the MVP /// rebuilt for the overlay's origin. setOverlay writes it, and it is the /// only thing that fills overlay_buf, so a buffer to draw always has a /// uniform to draw it with. @@ -1516,6 +1516,11 @@ pub const Gpu = struct { for (self.batchScene(s)) |dr| { const tri = dr.prim == .triangles; var uu = u; + // Tile geometry paints only its own tile. The buffered + // overhang two neighbours share must not blend twice + // (scene.CLIP_NONE). Quads are set to CLIP_NONE and draw + // unclipped. + uu.clip_rect = dr.clip; var pso: ?*d3d.ID3D12PipelineState = null; var tex_gpu: u64 = 0; const halo = dr.pipeline == .sdf; @@ -1616,7 +1621,7 @@ pub const Gpu = struct { /// Draw the overlay LAST — after the whole chart, in the same command list. /// The overlay carries its OWN uniform (setOverlay): the shader reads mvp - /// and wrap_x, and both are built for the overlay's origin, not the + /// and it is built for the overlay's origin rather than the /// chart's. It goes in the ring like every other draw's. fn recordOverlay(self: *Gpu, cmd: *d3d.ID3D12GraphicsCommandList) void { if (self.overlay_count == 0 or self.overlay_buf.res == null) return; diff --git a/src/gpu/gpu_metal.zig b/src/gpu/gpu_metal.zig index a654189..c73fbad 100644 --- a/src/gpu/gpu_metal.zig +++ b/src/gpu/gpu_metal.zig @@ -476,9 +476,15 @@ pub const Gpu = struct { } // One merged front-to-back run of the opaque pre-pass. - const OpaqueRun = struct { first: u32, count: u32, pattern: u32 }; - - fn flushOpaque(self: *const Gpu, f: *mc.ctm_frame, s: *const Scene, run: OpaqueRun, last_u: *?Uniforms, u: *const Uniforms) void { + const OpaqueRun = struct { first: u32, count: u32, pattern: u32, clip: [4]f32 }; + + fn flushOpaque(self: *const Gpu, f: *mc.ctm_frame, s: *const Scene, run: OpaqueRun, last_u: *?Uniforms, u_in: *const Uniforms) void { + // Phase A clips as phase B does. A tile paints its own tile in both + // passes, so the depth this pass writes describes the same ground the + // blended pass reads. + var uv = u_in.*; + uv.clip_rect = run.clip; + const u = &uv; if (run.pattern == scene.NO_PATTERN) { mc.ctm_set_pipeline(f, mc.CTM_PIPE_FILL); mc.ctm_bind_vbuf(f, s.vbuf.?); @@ -581,14 +587,16 @@ pub const Gpu = struct { const r = s.ranges[i]; if (r.count == 0 or r.prim != .triangles or (r.flags & scene.Range.FLAG_OPAQUE) == 0) continue; if (run) |*a| { - if (a.pattern == r.pattern and r.first + r.count == a.first) { + if (a.pattern == r.pattern and std.mem.eql(f32, &a.clip, &r.clip) and + r.first + r.count == a.first) + { a.first = r.first; a.count += r.count; continue; } self.flushOpaque(f, s, a.*, &last_u, &u); } - run = .{ .first = r.first, .count = r.count, .pattern = r.pattern }; + run = .{ .first = r.first, .count = r.count, .pattern = r.pattern, .clip = r.clip }; } if (run) |a| self.flushOpaque(f, s, a, &last_u, &u); } @@ -605,6 +613,11 @@ pub const Gpu = struct { const draws: []const scene.Draw = if (n > s.draws.len) &.{} else s.draws[0..n]; for (draws) |d| { var uu = u; + // Tile geometry paints only its own tile. The buffered + // overhang two neighbours share must not blend twice + // (scene.CLIP_NONE). Quads are set to CLIP_NONE and draw + // unclipped. + uu.clip_rect = d.clip; switch (d.prim) { .triangles => { if (!tris_ready) continue; @@ -810,7 +823,7 @@ test "metal offscreen smoke: paint stream draws, zoom gate culls" { }); var u = std.mem.zeroes(Uniforms); - u.mvp[0] = 2; // scale x2 into clip; verts stay within the wrap-stable half-world + u.mvp[0] = 2; // scale x2 into clip u.mvp[5] = 2; u.mvp[15] = 1; u.px_to_clip = .{ 2.0 / 256.0, -2.0 / 256.0 }; diff --git a/src/gpu/gpu_vk.zig b/src/gpu/gpu_vk.zig index b75c458..56b3887 100644 --- a/src/gpu/gpu_vk.zig +++ b/src/gpu/gpu_vk.zig @@ -322,8 +322,8 @@ pub const Gpu = struct { overlay_buf: Buffer = .{}, overlay_count: u32 = 0, overlay_gen: u64 = 0, // 0 = nothing uploaded yet (a built store is >= 1) - /// The overlay pass's own frame uniform — the chart's, with the MVP and - /// wrap rebuilt for the overlay's origin. setOverlay writes it, and it is + /// The overlay pass's own frame uniform: the chart's, with the MVP + /// rebuilt for the overlay's origin. setOverlay writes it, and it is /// the only thing that fills overlay_buf, so a buffer to draw always has a /// uniform to draw it with. overlay_u: Uniforms = std.mem.zeroes(Uniforms), @@ -1685,8 +1685,8 @@ pub const Gpu = struct { /// Draw the overlay LAST — after the raster underlay and the whole chart, /// in the same render pass. The overlay carries its OWN uniform - /// (setOverlay): the shader reads mvp and wrap_x, and both are built for - /// the overlay's origin, not the chart's. It goes in the ring like every + /// (setOverlay): the shader reads mvp, and it is built for + /// the overlay's origin rather than the chart's. It goes in the ring like every /// other draw's, on set 1. fn recordOverlay(self: *Gpu, cmd: vk.VkCommandBuffer) void { if (self.overlay_count == 0 or self.overlay_buf.buf == null) return; @@ -2025,6 +2025,11 @@ pub const Gpu = struct { for (self.batchScene(s)) |d| { const tri = d.prim == .triangles; var uu = u; + // Tile geometry paints only its own tile. The buffered + // overhang two neighbours share must not blend twice + // (scene.CLIP_NONE). Quads are set to CLIP_NONE and draw + // unclipped. + uu.clip_rect = d.clip; var pipe: vk.VkPipeline = null; var tex_set: vk.VkDescriptorSet = null; const halo = d.pipeline == .sdf; @@ -2099,6 +2104,11 @@ pub const Gpu = struct { uu.color = d.color; const foff = self.pushUniform(&uu) orelse continue; vk.vkCmdBindDescriptorSets(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self.pipe_layout, 3, 1, &self.frag_uni_set, 1, &foff); + } else if (tri) { + // fill.frag and pattern.frag read clip_rect from set 3. The + // bytes are the vertex stage's, so the ring entry is reused. + var voff2 = voff; + vk.vkCmdBindDescriptorSets(cmd, vk.VK_PIPELINE_BIND_POINT_GRAPHICS, self.pipe_layout, 3, 1, &self.frag_uni_set, 1, &voff2); } if (tri) { vk.vkCmdDrawIndexed(cmd, d.count, 1, d.first, 0, 0); diff --git a/src/gpu/metal_shim.m b/src/gpu/metal_shim.m index c13c48d..d4f2d97 100644 --- a/src/gpu/metal_shim.m +++ b/src/gpu/metal_shim.m @@ -540,7 +540,8 @@ void ctm_bind_texture(ctm_frame *f, ctm_tex *t) { void ctm_set_uniforms(ctm_frame *f, const void *bytes, size_t len) { if (!f) return; [f->enc setVertexBytes:bytes length:len atIndex:2]; - // The SDF text fragment stage reads the uniform too (halo color). + // Fragment stages read it too, for the SDF halo colour and for the clip + // rect a triangle draw is trimmed to. [f->enc setFragmentBytes:bytes length:len atIndex:1]; } diff --git a/src/layout/fill.zig b/src/layout/fill.zig index 9482a80..770d5c6 100644 --- a/src/layout/fill.zig +++ b/src/layout/fill.zig @@ -21,7 +21,9 @@ //! tile57 fed libtess2 for area fills. //! - Output positions are tile-local world units: tile_span * coord / extent. //! The 64-unit buffer overhang stays in the geometry — clipping is the -//! renderer's job (per-tile clip rects), never layout's. +//! renderer's job (every triangle draw holds the bounds of the tile it came +//! from, and the fragment stage trims to them: scene.CLIP_NONE), never +//! layout's. //! - Fill vertices carry no screen offset: ox/oy zero, flags 0. Zoom window //! and paint-order depth pass through Options. //! - Degenerate rings (< 3 distinct points, zero area) draw nothing and are diff --git a/src/map.zig b/src/map.zig index 14b6746..18da714 100644 --- a/src/map.zig +++ b/src/map.zig @@ -42,6 +42,12 @@ pub const SourcedTile = struct { /// Empty means unlabeled, which matches any layer: a caller with one /// source has nothing to disambiguate. source: []const u8 = "", + /// Which world copy to draw this tile at: 0 for the one nearest the view + /// origin, -1 or +1 for the copy a world away. Longitude is cyclic, and a + /// wide enough view shows the same tile on both sides + /// (Camera.wrappedCopy), so that tile is listed twice with different + /// values here. + wrap: f64 = 0, }; /// The build's output: everything Gpu.SceneData borrows, plus the effective @@ -289,6 +295,8 @@ pub const RasterTile = struct { /// the starting alpha (in: 0, out: opaque) and records the quad span in /// Built.fades; the Map animates it through the quad paint stream. fade: Fade = .none, + /// Which world copy to draw at. See SourcedTile.wrap. + wrap: f64 = 0, pub const Fade = enum(u2) { none, in, out }; }; @@ -896,8 +904,23 @@ pub fn buildSceneWithRasters( // that column's own world position puts it on the wrong side of // the map. Harmless while the viewport is a sliver of the world; // at low zoom it scrambles the geography. - const dx: f32 = @floatCast(cameras.wrapDx(rect.x0, view.origin.x)); + // + // Measured at the tile's CENTRE, so the whole tile goes on one + // copy. Measured at the left edge, the body of the tile reaches a + // full span past the half-world seam, and at low zoom that is + // most of the tile. + const dx: f32 = @floatCast(cameras.placeTileX(rect.x0, tile_span, view.origin.x) + st.wrap); const dy: f32 = @floatCast(rect.y0 - view.origin.y); + // What this tile's triangles may paint. An MVT tile is buffered, + // so the geometry reaches past these bounds and two neighbours + // both hold the strip along the edge they share. See + // types.CLIP_NONE. + const tile_clip = [4]f32{ + dx, + dy, + dx + @as(f32, @floatCast(tile_span)), + dy + @as(f32, @floatCast(rect.y1 - rect.y0)), + }; const tile_quads_first: u32 = @intCast(quads.items.len); var text_scratch: std.ArrayList(types.Quad) = .empty; var text_paint_scratch: std.ArrayList(types.PaintVertex) = .empty; @@ -994,6 +1017,7 @@ pub fn buildSceneWithRasters( .count = @intCast(indices.items.len - run_first), .paint_key = @intCast(layer_i), .pattern = run_pattern, + .clip = tile_clip, .kind = .pattern, .prim = .triangles, }); @@ -1132,6 +1156,7 @@ pub fn buildSceneWithRasters( .count = count, .paint_key = @intCast(layer_i), .pattern = run_pattern, + .clip = tile_clip, .kind = if (is_pattern) .pattern else if (sl.kind == .fill) .area else .line, .prim = .triangles, // A pattern cell is mostly transparent: it must blend over @@ -1230,7 +1255,7 @@ fn layoutDemLayer( } const rect = rt.id.worldRect(); - const x0: f32 = @floatCast(cameras.wrapDx(rect.x0, view.origin.x)); + const x0: f32 = @floatCast(cameras.placeTileX(rect.x0, rect.x1 - rect.x0, view.origin.x) + rt.wrap); const y0: f32 = @floatCast(rect.y0 - view.origin.y); const x1: f32 = x0 + @as(f32, @floatCast(rect.x1 - rect.x0)); const y1: f32 = @floatCast(rect.y1 - view.origin.y); @@ -1364,7 +1389,7 @@ fn layoutRasterLayer( if (rt.rgba.len < @as(usize, rt.w) * rt.h * 4) continue; const rect = rt.id.worldRect(); - const x0: f32 = @floatCast(cameras.wrapDx(rect.x0, view.origin.x)); + const x0: f32 = @floatCast(cameras.placeTileX(rect.x0, rect.x1 - rect.x0, view.origin.x) + rt.wrap); const y0: f32 = @floatCast(rect.y0 - view.origin.y); const x1: f32 = x0 + @as(f32, @floatCast(rect.x1 - rect.x0)); const y1: f32 = @floatCast(rect.y1 - view.origin.y); @@ -1513,6 +1538,15 @@ pub fn concatScenes(arena: std.mem.Allocator, parts: []const ScenePart) !Built { .triangles => ibase, .quads => qbase, }; + // The clip is stated in the part's own frame, like its vertices. + if (!std.mem.eql(f32, &r.clip, &types.CLIP_NONE)) { + moved.clip = .{ + r.clip[0] + part.dx, + r.clip[1] + part.dy, + r.clip[2] + part.dx, + r.clip[3] + part.dy, + }; + } if (r.pattern != types.NO_PATTERN) moved.pattern = r.pattern + pbase; ranges.appendAssumeCapacity(moved); } @@ -1986,7 +2020,7 @@ test "first light: style to pixels through the GPU backend" { .size_scale = 1, .zoom = @floatFromInt(types.zq(cam.zoom)), .zoom_t = 0, - .wrap_x = @floatCast(cam.center.x - origin.x), + .world_per_px = @floatCast(1.0 / cam.worldToPx()), .rot_sin = 0, .rot_cos = 1, .color = .{ 0, 0, 0, 0 }, @@ -2151,7 +2185,7 @@ test "real chart: Annapolis first light" { .size_scale = 1, .zoom = @floatFromInt(types.zq(cam.zoom)), .zoom_t = 0, - .wrap_x = 0, + .world_per_px = 0, .rot_sin = 0, .rot_cos = 1, .color = .{ 0, 0, 0, 0 }, @@ -2641,7 +2675,7 @@ test "concatScenes: per-tile geometry plus a global symbol pass equals one build }, .{}); try parts.append(a, .{ .built = part, - .dx = @floatCast(cameras.wrapDx(rect.x0, view.origin.x)), + .dx = @floatCast(cameras.placeTileX(rect.x0, rect.x1 - rect.x0, view.origin.x)), .dy = @floatCast(rect.y0 - view.origin.y), }); } diff --git a/src/map_object.zig b/src/map_object.zig index a09db76..c4bfd99 100644 --- a/src/map_object.zig +++ b/src/map_object.zig @@ -1203,7 +1203,9 @@ pub const Map = struct { // extrapolates smoothly and the color mix clamps at the end it // was already approaching (scene/types.zig Uniforms.zoom_t). .zoom_t = @floatCast(self.cam.zoom - (if (self.built) |b| b.paint_zoom_floor else @floor(self.cam.zoom))), - .wrap_x = @floatCast(cameras.wrapDx(self.cam.center.x, origin.x)), + // World units per reference pixel, for turning a vertex's screen + // offset back into a world delta at clip time. + .world_per_px = @floatCast(1.0 / self.cam.worldToPx()), .rot_sin = rs[0], .rot_cos = rs[1], .color = .{ 0, 0, 0, 0 }, @@ -1696,6 +1698,10 @@ pub const Map = struct { // would outlive the eviction that frees it. var parts: std.ArrayListUnmanaged(map.ScenePart) = .empty; defer parts.deinit(self.gpa); + // How far the scene has to reach either side of the origin, so a tile + // the view sees on BOTH sides of the world can be drawn on both. + const half_w = self.extentsAt(zoom).x; + var tile_parts: usize = 0; var vector_tiles: std.ArrayListUnmanaged(map.SourcedTile) = .empty; var rasters: std.ArrayListUnmanaged(map.RasterTile) = .empty; // How many tiles this pass may TESSELLATE. Cached buckets are free @@ -1728,11 +1734,26 @@ pub const Map = struct { switch (self.cache.sourceKind(key)) { .vector => { const tile = self.cache.get(key) orelse continue; + const rect = key.tileId().worldRect(); + const span = rect.x1 - rect.x0; + // The nearest world copy: see the note in map.zig. A + // wide enough view shows the same tile on both sides of + // the world, and it is then listed and placed twice. + const dx = cameras.placeTileX(rect.x0, span, origin.x); + const second = cameras.wrappedCopy(dx, span, half_w); try vector_tiles.append(a, .{ .id = key.tileId(), .tile = tile, .source = self.sourceName(key.source), }); + if (second) |copy_dx| { + try vector_tiles.append(a, .{ + .id = key.tileId(), + .tile = tile, + .source = self.sourceName(key.source), + .wrap = copy_dx, + }); + } if (!self.bucketReady(key, zoom)) { if (budget == 0) { deferred += 1; @@ -1741,17 +1762,27 @@ pub const Map = struct { budget -= 1; } const bucket = (try self.bucketFor(style, key, zoom)) orelse continue; - const rect = key.tileId().worldRect(); + const dy: f32 = @floatCast(rect.y0 - origin.y); try parts.append(self.gpa, .{ .built = bucket.built, - // The nearest world copy: see the note in map.zig. - .dx = @floatCast(cameras.wrapDx(rect.x0, origin.x)), - .dy = @floatCast(rect.y0 - origin.y), + .dx = @floatCast(dx), + .dy = dy, }); + tile_parts += 1; + // The same bucket, placed a world over. The second copy + // costs a rebase at concatenation rather than a + // tessellation. + if (second) |copy_dx| { + try parts.append(self.gpa, .{ + .built = bucket.built, + .dx = @floatCast(dx + copy_dx), + .dy = dy, + }); + } }, .raster => { const img = self.cache.getRaster(key) orelse continue; - try rasters.append(a, .{ + const rt = map.RasterTile{ .id = key.tileId(), .source = self.sourceName(key.source), .w = img.w, @@ -1760,7 +1791,16 @@ pub const Map = struct { // A source whose level swapped fades its new tiles // in from invisible over the outgoing ones. .fade = if (self.build_fade_sources & (@as(u8, 1) << key.source) != 0) .in else .none, - }); + }; + try rasters.append(a, rt); + const rect = key.tileId().worldRect(); + const span = rect.x1 - rect.x0; + const dx = cameras.placeTileX(rect.x0, span, origin.x); + if (cameras.wrappedCopy(dx, span, half_w)) |copy_dx| { + var copy = rt; + copy.wrap = copy_dx; + try rasters.append(a, copy); + } }, } } @@ -1797,7 +1837,9 @@ pub const Map = struct { // Tiles whose geometry is actually IN the scene: `have` less // whatever the budget deferred. The gap is what a test can watch // to catch the scene silently losing ground. - .scene_tiles = parts.items.len - 1, // less the global pass + // Distinct tiles rather than parts. A tile the view sees on both + // sides of the world is placed twice and is still one tile. + .scene_tiles = tile_parts, .partial = deferred > 0, .origin = origin, .zoom = zoom, diff --git a/src/scene/batch.zig b/src/scene/batch.zig index 300a6da..379ea4d 100644 --- a/src/scene/batch.zig +++ b/src/scene/batch.zig @@ -60,9 +60,16 @@ pub fn batch(ranges: []const t.Range, opts: t.BatchOpts, out: []t.Draw) usize { @as(f32, @floatFromInt(r.halo[3])) / 255.0, } else opts.halo; + // The clip joins the merge key, because two tiles' ranges paint + // different ground and cannot share a draw (types.CLIP_NONE). The + // draw count is unchanged in practice: parts concatenate tile-major, + // so one layer's ranges from two tiles are adjacent in paint order + // and far apart in the index buffer, where the contiguity test + // already separates them. if (last) |d| { if (d.prim == r.prim and d.pipeline == pipe and d.atlas == atlas and d.pattern == r.pattern and std.mem.eql(f32, &d.color, &color) and + std.mem.eql(f32, &d.clip, &r.clip) and d.first + d.count == r.first) { d.count += r.count; @@ -78,6 +85,7 @@ pub fn batch(ranges: []const t.Range, opts: t.BatchOpts, out: []t.Draw) usize { .atlas = atlas, .pattern = r.pattern, .color = color, + .clip = r.clip, }; last = &out[n]; } else { @@ -176,6 +184,24 @@ test "a range's own halo color wins over the scene background" { try expectEqual([4]f32{ 0, 0, 0, 0 }, out[0].color); } +// Tile geometry paints only its own tile (types.CLIP_NONE), so two tiles' +// ranges do not merge even when their index spans are contiguous. +test "ranges from different tiles do not merge" { + var a = tri(0, 30, false); + a.clip = .{ 0, 0, 1, 1 }; + var b = tri(30, 12, false); + b.clip = .{ 1, 0, 2, 1 }; + var out: [2]t.Draw = undefined; + const n = batch(&.{ a, b }, .{ .atlas_have = all_atlases, .halo = halo }, &out); + try expectEqual(@as(usize, 2), n); + try expectEqual([4]f32{ 0, 0, 1, 1 }, out[0].clip); + try expectEqual([4]f32{ 1, 0, 2, 1 }, out[1].clip); + // Two ranges from the same tile still merge. + var c = tri(30, 12, false); + c.clip = a.clip; + try expectEqual(@as(usize, 1), batch(&.{ a, c }, .{ .atlas_have = all_atlases, .halo = halo }, &out)); +} + test "pattern ranges classify to the pattern pipeline and split on cell change" { var a = tri(0, 12, false); a.pattern = 0; diff --git a/src/scene/types.zig b/src/scene/types.zig index 2b4620a..8fba85d 100644 --- a/src/scene/types.zig +++ b/src/scene/types.zig @@ -205,6 +205,10 @@ pub const Range = extern struct { count: u32, paint_key: u32, pattern: u32 = NO_PATTERN, // into the scene's patterns, pattern ranges only + /// The bounds of the tile this range's geometry came from, world units + /// in the scene frame. See CLIP_NONE. Triangle ranges only. Every other + /// range is set to CLIP_NONE. + clip: [4]f32 = CLIP_NONE, kind: Kind, prim: Prim, atlas: Atlas = .none, @@ -244,11 +248,15 @@ pub const Draw = extern struct { _pad: u8 = 0, pattern: u32, // look up YOUR cell texture; derive cell_px from its size color: [4]f32, // the uniform's color (halo on SDF draws) + /// Copied into Uniforms.clip_rect. The batcher resolves it, so a backend + /// does not look it up. + clip: [4]f32 = CLIP_NONE, }; /// The per-draw uniform block the shaders read, byte for byte. The layout is /// not the host's to choose: it is the other half of the vertex contract. -/// std140 and C agree on this order: color at byte 96, the block 128 bytes. +/// std140 and C agree on this order: color at byte 96, clip_rect at 128, the +/// block 144 bytes. pub const Uniforms = extern struct { mvp: [16]f32, // column-major tile-local -> clip (Camera.mvpOrigin) px_to_clip: [2]f32, // reference-px -> clip delta (the ox/oy channel) @@ -262,22 +270,50 @@ pub const Uniforms = extern struct { /// slope is the continuous answer and snapping back to a bracket end is /// exactly the jump this field exists to remove. zoom_t: f32, - wrap_x: f32, // camera centre world-x (antimeridian wrap) + /// World units per reference pixel, the reciprocal of Camera.worldToPx. + /// It converts a vertex's screen-space (ox, oy) into a world delta, so the + /// fragment stage tests the position a line's stroke covers against + /// clip_rect instead of the position of its anchor. + /// + /// This slot held a per-vertex antimeridian wrap until it split every + /// primitive lying across the half-world seam. The host picks the world + /// copy now, in Camera.placeTileX and Camera.mvpOrigin. + world_per_px: f32, rot_sin: f32, rot_cos: f32, color: [4]f32, // SDF halo background; SDF fragment stage only anchor_px: [2]f32, // pattern phase origin, framebuffer px cell_px: [2]f32, // pattern cell period, framebuffer px + /// The tile a triangle draw belongs to, in the scene frame: (x0, y0, x1, + /// y1). The fragment stage drops fragments outside it. See CLIP_NONE. + clip_rect: [4]f32 = CLIP_NONE, }; +/// The clip_rect that admits every fragment. +/// +/// A triangle draw's clip is the bounds of the tile its geometry came from, +/// in world units in the scene frame. An MVT tile holds its features clipped +/// to its own bounds plus an overhang, so two neighbours both hold the strip +/// along the edge they share. A line's joins and caps are built from that +/// overhang, so it stays in the geometry. Painting it twice blends a +/// translucent fill twice, and the tile grid then shows through the map as a +/// lattice of darker strips, so the draw trims it. The test is in world space +/// because a scissor rect cannot express a tile under a rotated view. +/// +/// CLIP_NONE covers the ranges with no tile of their own: symbols, whose +/// labels may overhang the tile holding the anchor, raster quads, which are +/// already one tile with no overhang, and the host overlay. +pub const CLIP_NONE: [4]f32 = .{ -1e30, -1e30, 1e30, 1e30 }; + comptime { std.debug.assert(@sizeOf(Vertex) == 28); std.debug.assert(@sizeOf(Quad) == 40); std.debug.assert(@sizeOf(PaintVertex) == 4); - std.debug.assert(@sizeOf(Range) == 24); - std.debug.assert(@sizeOf(Uniforms) == 128); + std.debug.assert(@sizeOf(Range) == 40); + std.debug.assert(@sizeOf(Uniforms) == 144); std.debug.assert(@offsetOf(Uniforms, "color") == 96); std.debug.assert(@offsetOf(Uniforms, "anchor_px") == 112); + std.debug.assert(@offsetOf(Uniforms, "clip_rect") == 128); } /// Layout guard: packs the sizes the shaders and backends assume so an ABI