Crafter.Graphics/shaders/ui-fused.comp.glsl

362 lines
16 KiB
Text
Raw Normal View History

2026-07-22 18:09:06 +02:00
//SPDX-License-Identifier: LGPL-3.0-only
//SPDX-FileCopyrightText: Copyright (C) 2026 Catcrafts®
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
#version 460
#extension GL_GOOGLE_include_directive : enable
perf(ui): subgroup prefix-sum stream compaction, not lane-0 serial scan (#124) uiCompactChunk ran the survivor scan entirely on gl_LocalInvocationIndex == 0: ~64 serial iterations with 63/64 lanes idle, between two barriers, 4x per chunk in the fused kernel. It must emit a stable in-order (buffer-order) exclusive prefix of the survivors so the per-pixel inner loop still sees items in draw order. Replace it with a per-subgroup subgroupExclusiveAdd of the keep bits plus a carry across subgroups: each subgroup publishes its survivor total to shared memory, then every lane sums the totals of all lower-id subgroups for its base. A survivor's slot in s_order[] therefore equals the number of survivors with a smaller local index — buffer (draw) order preserved exactly, unlike an atomicAdd which would scramble it. The carry makes the result correct for any subgroup width (2 subgroups on the 32-wide descriptor_heap target, up to 8/16 on narrower parts), relying only on the gl_LocalInvocationIndex <-> (gl_SubgroupID, gl_SubgroupInvocationID) linear mapping every Vulkan compute implementation provides. The feature is free at the device baseline (apiVersion 1.4 + VK_EXT_descriptor_heap implies Vulkan 1.1 subgroup arithmetic), so no correctness fallback is needed; WebGPU is unaffected (separate embedded WGSL). Applied to ui-fused.comp.glsl and the same pattern inlined in ui-quads/circles/images/text. Verified: all 23 tests pass (UIFusedShader recompiles + spirv-val + pins push-constant offsets); a 140k-trial CPU simulation of the algorithm matches the serial reference for subgroup sizes 1..64; HelloUI renders correctly on an RTX 4090 (DispatchFused quads+circles+text) with draw order intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:26:37 +00:00
#extension GL_KHR_shader_subgroup_basic : enable
#extension GL_KHR_shader_subgroup_arithmetic : enable
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
#include "ui-shared.glsl"
// ─── fused UI uber-kernel (issue #47) ────────────────────────────────────
// One dispatch that composites up to four standard categories in canonical
// back-to-front order — quads → circles → images → text — into a single
// per-pixel register, loading the destination image ONCE and storing it ONCE.
// A run of consecutive Dispatch* calls would instead load+store the image per
// category and drain compute with a VkMemoryBarrier between each; this kernel
// collapses all of that to 1 load + 1 store + 0 inter-pass barriers.
//
// Each category keeps the exact cooperative shared-memory tile-cull + per-pixel
// accumulate of its standalone shader (see ui-quads/circles/images/text), so a
// fused category is pixel-identical to its standalone Dispatch* pass (the only
// difference is that intermediate results stay in full float precision between
// categories instead of round-tripping through the storage image — strictly
// more accurate). Categories run sequentially within each thread, so the VGPR
// high-water mark is ~max(per-category), not the sum.
//
// An absent category (itemCount 0) is a zero-trip, push-constant-uniform loop:
// no divergence, no memory traffic — using DispatchFused for only quads+text
// costs ~nothing for the unused circle/image phases.
//
// This is ADDITIVE: it has its own push-constant layout and does NOT touch the
// frozen 48-byte UIDispatchHeader or the per-element Dispatch* contract.
// Push-constant block. Mirrors Crafter::UIFusedHeader byte-for-byte: every
// member is vec4-aligned, no padding holes, exactly 128 bytes (the guaranteed
// push-constant minimum).
layout(push_constant) uniform PC {
uvec4 itemBuffers; // heap slots: (quads, circles, images, text)
uvec4 itemCounts; // item counts: (quads, circles, images, text)
uvec4 misc; // (outImage, fontTexture, fontSampler, flags)
uvec4 surface; // (surfaceWidth, surfaceHeight, frameIdx, _pad)
vec4 clipQuads;
vec4 clipCircles;
vec4 clipImages;
vec4 clipText;
} pc;
layout(local_size_x = 8, local_size_y = 8, local_size_z = 1) in;
// Per-category clip-active bits packed into pc.misc.w (the fused analogue of
// ui-shared.glsl's UI_FLAG_CLIP). A bit is set only when that category's clip
// rect is narrower than the surface; when clear, the category skips its four
// per-pixel clip compares entirely (the common full-surface case). The branch
// is push-constant-uniform, so it never diverges.
const uint UI_FUSED_CLIP_QUADS = 0x1u;
const uint UI_FUSED_CLIP_CIRCLES = 0x2u;
const uint UI_FUSED_CLIP_IMAGES = 0x4u;
const uint UI_FUSED_CLIP_TEXT = 0x8u;
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
// Generic per-chunk cooperative-cull scratch, REUSED across the four phases
// (they composite sequentially with a barrier between, so the storage is free
// once a phase's last read completes). Keeping one shared set instead of four
// keeps the shared-memory footprint — and thus occupancy — at the per-category
// level. Member mapping per phase:
// quads: v0=rect v1=color v2=corners v3=outline
// circles: v0=centerRadius v1=color v2=outline
// images: v0=rect v1=uv v2=tint v4=slots
// text: v0=rect v1=uv v2=color
shared vec4 s_v0[UI_CHUNK];
shared vec4 s_v1[UI_CHUNK];
shared vec4 s_v2[UI_CHUNK];
shared vec4 s_v3[UI_CHUNK];
shared uvec4 s_v4[UI_CHUNK];
// Per-item constants precomputed once at cooperative-load time, REUSED across
// phases like the s_v* scratch (the per-pixel inner loops read them instead of
// recomputing per pixel × item):
// images: s_inv = 1.0/rect.zw
fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel Shaders - ui-text and ui-fused turned a sample of the R8_UNORM atlas into a distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each byte, so an atlas pixel is kPixelDistScale / 255 of a sample; 32 made every distance 1024/255 (about 4) times too large, and the coverage ramp, meant to span one screen pixel, spanned a quarter of one: nearly every edge pixel came out fully on or fully off, and text looked pixelated at every size. On an RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels where stb_truetype's own rasterizer has 415. Wrong since the v3 UI (1f56973), and copied into the fused kernel (574242d). - The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a glyph's ramp when it is loaded and uiGlyphCoverage the coverage per pixel. Both kernels call them, and a copy of either should; the UI_ prefix keeps them clear of a copied kernel's own names. - stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded value and the decode takes its middle: decoding b pulled every outline 1/64 atlas pixel inwards. - The ramp is capped at what a byte holds, about 4 atlas pixels each way of the outline. With the corrected decode a wider one left every glyph quad's padding translucent below 4 px. - The UV span is taken with abs(): a glyph turned by swapping its UVs got a negative scale, a ramp of 0.0001 and hard edges. - A glyph scaled differently along x and y ramps along the distance's gradient, from two one-texel differences. With the corrected decode, one width for both axes, the more minified one's, would leave a row stretched three times with stems three pixels soft. Glyphs scaled alike, as ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal worked out with the glyph. - The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded as 1024. WebGPU - The WGSL text kernel decoded the distance right but ramped over one atlas pixel whatever the size: half a screen pixel at 16 px, two at 64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient path, and renders TextAntialiasing's scene as Vulkan does (run through Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels, where the old kernel gave 0.55, 2.19 and 0.89). FontAtlas - kOnEdgeValue and kPixelDistScale say the kernels carry copies of them. Tests - TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched three times, a 'g' turned by its UVs and empty atlas texels shrunk to 12.8 atlas pixels per screen pixel, through DispatchText and again through DispatchFused, and holds them to stb_truetype's antialiased rasterizer, to the mirror image and to nothing. On master the two plain rows have 0.31 and 0.21 times the reference's edge pixels; with the decode fixed alone the stretched row had 1.62 times, the turned 'g' was 127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07 and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor sets. It skips a swapchain that isn't 8-bit, which the capture can't read, and captures the first frame. - FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their copies of the two constants to FontAtlas's. No GPU. Docs - AGENTS.consumer.md has a custom text shader decode through the helpers and gives the encoding; README says so in a line; AGENTS.md lists the tests and where the decode lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:22:12 +01:00
// text: s_inv = 1.0/rect.zw, s_ramp = the SDF edge ramp (ui-shared.glsl's
// uiGlyphRamp, as in ui-text.comp.glsl)
shared vec2 s_inv[UI_CHUNK];
fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel Shaders - ui-text and ui-fused turned a sample of the R8_UNORM atlas into a distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each byte, so an atlas pixel is kPixelDistScale / 255 of a sample; 32 made every distance 1024/255 (about 4) times too large, and the coverage ramp, meant to span one screen pixel, spanned a quarter of one: nearly every edge pixel came out fully on or fully off, and text looked pixelated at every size. On an RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels where stb_truetype's own rasterizer has 415. Wrong since the v3 UI (1f56973), and copied into the fused kernel (574242d). - The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a glyph's ramp when it is loaded and uiGlyphCoverage the coverage per pixel. Both kernels call them, and a copy of either should; the UI_ prefix keeps them clear of a copied kernel's own names. - stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded value and the decode takes its middle: decoding b pulled every outline 1/64 atlas pixel inwards. - The ramp is capped at what a byte holds, about 4 atlas pixels each way of the outline. With the corrected decode a wider one left every glyph quad's padding translucent below 4 px. - The UV span is taken with abs(): a glyph turned by swapping its UVs got a negative scale, a ramp of 0.0001 and hard edges. - A glyph scaled differently along x and y ramps along the distance's gradient, from two one-texel differences. With the corrected decode, one width for both axes, the more minified one's, would leave a row stretched three times with stems three pixels soft. Glyphs scaled alike, as ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal worked out with the glyph. - The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded as 1024. WebGPU - The WGSL text kernel decoded the distance right but ramped over one atlas pixel whatever the size: half a screen pixel at 16 px, two at 64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient path, and renders TextAntialiasing's scene as Vulkan does (run through Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels, where the old kernel gave 0.55, 2.19 and 0.89). FontAtlas - kOnEdgeValue and kPixelDistScale say the kernels carry copies of them. Tests - TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched three times, a 'g' turned by its UVs and empty atlas texels shrunk to 12.8 atlas pixels per screen pixel, through DispatchText and again through DispatchFused, and holds them to stb_truetype's antialiased rasterizer, to the mirror image and to nothing. On master the two plain rows have 0.31 and 0.21 times the reference's edge pixels; with the decode fixed alone the stretched row had 1.62 times, the turned 'g' was 127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07 and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor sets. It skips a swapchain that isn't 8-bit, which the capture can't read, and captures the first frame. - FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their copies of the two constants to FontAtlas's. No GPU. Docs - AGENTS.consumer.md has a custom text shader decode through the helpers and gives the encoding; README says so in a line; AGENTS.md lists the tests and where the decode lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:22:12 +01:00
shared UIGlyphRamp s_ramp[UI_CHUNK];
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
shared uint s_keep[UI_CHUNK];
shared uint s_order[UI_CHUNK];
shared uint s_count;
perf(ui): subgroup prefix-sum stream compaction, not lane-0 serial scan (#124) uiCompactChunk ran the survivor scan entirely on gl_LocalInvocationIndex == 0: ~64 serial iterations with 63/64 lanes idle, between two barriers, 4x per chunk in the fused kernel. It must emit a stable in-order (buffer-order) exclusive prefix of the survivors so the per-pixel inner loop still sees items in draw order. Replace it with a per-subgroup subgroupExclusiveAdd of the keep bits plus a carry across subgroups: each subgroup publishes its survivor total to shared memory, then every lane sums the totals of all lower-id subgroups for its base. A survivor's slot in s_order[] therefore equals the number of survivors with a smaller local index — buffer (draw) order preserved exactly, unlike an atomicAdd which would scramble it. The carry makes the result correct for any subgroup width (2 subgroups on the 32-wide descriptor_heap target, up to 8/16 on narrower parts), relying only on the gl_LocalInvocationIndex <-> (gl_SubgroupID, gl_SubgroupInvocationID) linear mapping every Vulkan compute implementation provides. The feature is free at the device baseline (apiVersion 1.4 + VK_EXT_descriptor_heap implies Vulkan 1.1 subgroup arithmetic), so no correctness fallback is needed; WebGPU is unaffected (separate embedded WGSL). Applied to ui-fused.comp.glsl and the same pattern inlined in ui-quads/circles/images/text. Verified: all 23 tests pass (UIFusedShader recompiles + spirv-val + pins push-constant offsets); a 140k-trial CPU simulation of the algorithm matches the serial reference for subgroup sizes 1..64; HelloUI renders correctly on an RTX 4090 (DispatchFused quads+circles+text) with draw order intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:26:37 +00:00
// Per-subgroup survivor totals, published once per subgroup for the carry step
// of the compaction below. Sized for the worst case of one subgroup per lane.
shared uint s_subTotals[UI_CHUNK];
// Stable in-order compaction of one chunk's survivors. Each subgroup computes
// an exclusive prefix-sum of its keep bits at register speed (subgroupExclusiveAdd),
// publishes its survivor total to shared memory, then every lane carries in the
// totals of all lower-id subgroups. A survivor's slot in s_order[] therefore
// equals the number of survivors with a smaller local index, so the per-pixel
// inner loop still sees items in buffer (draw) order. This replaces the old
// lane-0 serial scan (~64 iterations with 63/64 lanes idle, run 4× per chunk).
//
// Order preservation relies on the gl_LocalInvocationIndex ↔ (gl_SubgroupID,
// gl_SubgroupInvocationID) linear mapping that every Vulkan compute
// implementation provides; the carry across subgroups makes the result correct
// for any subgroup width (2 subgroups on the 32-wide descriptor_heap target, up
// to 8 on 8-wide parts). Out-of-range lanes carry s_keep==0, so they neither
// add to a total nor write a slot — no separate bound is needed.
void uiCompactChunk() {
uint lid = gl_LocalInvocationIndex;
uint keep = s_keep[lid];
uint subPrefix = subgroupExclusiveAdd(keep); // rank within this subgroup
uint subTotal = subgroupAdd(keep); // survivors in this subgroup
if (subgroupElect())
s_subTotals[gl_SubgroupID] = subTotal;
barrier();
uint base = 0u;
uint total = 0u;
for (uint s = 0u; s < gl_NumSubgroups; ++s) {
uint t = s_subTotals[s];
if (s < gl_SubgroupID) base += t; // survivors in lower subgroups
total += t;
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
}
perf(ui): subgroup prefix-sum stream compaction, not lane-0 serial scan (#124) uiCompactChunk ran the survivor scan entirely on gl_LocalInvocationIndex == 0: ~64 serial iterations with 63/64 lanes idle, between two barriers, 4x per chunk in the fused kernel. It must emit a stable in-order (buffer-order) exclusive prefix of the survivors so the per-pixel inner loop still sees items in draw order. Replace it with a per-subgroup subgroupExclusiveAdd of the keep bits plus a carry across subgroups: each subgroup publishes its survivor total to shared memory, then every lane sums the totals of all lower-id subgroups for its base. A survivor's slot in s_order[] therefore equals the number of survivors with a smaller local index — buffer (draw) order preserved exactly, unlike an atomicAdd which would scramble it. The carry makes the result correct for any subgroup width (2 subgroups on the 32-wide descriptor_heap target, up to 8/16 on narrower parts), relying only on the gl_LocalInvocationIndex <-> (gl_SubgroupID, gl_SubgroupInvocationID) linear mapping every Vulkan compute implementation provides. The feature is free at the device baseline (apiVersion 1.4 + VK_EXT_descriptor_heap implies Vulkan 1.1 subgroup arithmetic), so no correctness fallback is needed; WebGPU is unaffected (separate embedded WGSL). Applied to ui-fused.comp.glsl and the same pattern inlined in ui-quads/circles/images/text. Verified: all 23 tests pass (UIFusedShader recompiles + spirv-val + pins push-constant offsets); a 140k-trial CPU simulation of the algorithm matches the serial reference for subgroup sizes 1..64; HelloUI renders correctly on an RTX 4090 (DispatchFused quads+circles+text) with draw order intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:26:37 +00:00
if (lid == 0u) s_count = total;
if (keep != 0u)
s_order[base + subPrefix] = lid;
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
}
void main() {
// NOTE: do not early-return — every thread must reach the barriers below.
uvec2 pxu = gl_GlobalInvocationID.xy;
bool inSurface = pxu.x < pc.surface.x && pxu.y < pc.surface.y;
ivec2 screenPx = ivec2(pxu);
vec2 sp = vec2(screenPx) + 0.5;
// Single load of the destination for the whole surface.
vec4 dst = vec4(0.0);
if (inSurface) dst = imageLoad(uiImages[pc.misc.x], screenPx);
vec2 tileMin, tileMax;
uiTileBounds(tileMin, tileMax);
uint lid = gl_LocalInvocationIndex;
// ─── QUADS ────────────────────────────────────────────────────────────
{
uint heap = pc.itemBuffers.x;
uint count = pc.itemCounts.x;
bool inClip = inSurface &&
((pc.misc.w & UI_FUSED_CLIP_QUADS) == 0u || uiPixelInClipRect(pxu, pc.clipQuads));
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
for (uint base = 0u; base < count; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < count) {
s_v0[lid] = uiQuadHeap[heap].items[idx].rect;
s_v1[lid] = uiQuadHeap[heap].items[idx].color;
s_v2[lid] = uiQuadHeap[heap].items[idx].corners;
s_v3[lid] = uiQuadHeap[heap].items[idx].outline;
keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw,
tileMin, tileMax);
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
perf(ui): subgroup prefix-sum stream compaction, not lane-0 serial scan (#124) uiCompactChunk ran the survivor scan entirely on gl_LocalInvocationIndex == 0: ~64 serial iterations with 63/64 lanes idle, between two barriers, 4x per chunk in the fused kernel. It must emit a stable in-order (buffer-order) exclusive prefix of the survivors so the per-pixel inner loop still sees items in draw order. Replace it with a per-subgroup subgroupExclusiveAdd of the keep bits plus a carry across subgroups: each subgroup publishes its survivor total to shared memory, then every lane sums the totals of all lower-id subgroups for its base. A survivor's slot in s_order[] therefore equals the number of survivors with a smaller local index — buffer (draw) order preserved exactly, unlike an atomicAdd which would scramble it. The carry makes the result correct for any subgroup width (2 subgroups on the 32-wide descriptor_heap target, up to 8/16 on narrower parts), relying only on the gl_LocalInvocationIndex <-> (gl_SubgroupID, gl_SubgroupInvocationID) linear mapping every Vulkan compute implementation provides. The feature is free at the device baseline (apiVersion 1.4 + VK_EXT_descriptor_heap implies Vulkan 1.1 subgroup arithmetic), so no correctness fallback is needed; WebGPU is unaffected (separate embedded WGSL). Applied to ui-fused.comp.glsl and the same pattern inlined in ui-quads/circles/images/text. Verified: all 23 tests pass (UIFusedShader recompiles + spirv-val + pins push-constant offsets); a 140k-trial CPU simulation of the algorithm matches the serial reference for subgroup sizes 1..64; HelloUI renders correctly on an RTX 4090 (DispatchFused quads+circles+text) with draw order intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:26:37 +00:00
uiCompactChunk();
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
barrier();
if (inClip) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 lo = s_v0[c].xy;
vec2 hi = s_v0[c].xy + s_v0[c].zw;
if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 halfSize = s_v0[c].zw * 0.5;
vec2 p = sp - (s_v0[c].xy + halfSize);
float d = uiSdRoundRect(p, halfSize, s_v2[c]);
vec4 outline = s_v3[c];
float bodyA = clamp(0.5 - d, 0.0, 1.0);
if (bodyA <= 0.0 && outline.x <= 0.0) continue;
vec4 col = s_v1[c];
vec4 src = vec4(col.rgb, col.a * bodyA);
if (outline.x > 0.0) {
float t = abs(d + outline.x * 0.5) - outline.x * 0.5;
float outlineA = clamp(0.5 - t, 0.0, 1.0);
src.rgb = mix(src.rgb, outline.yzw, outlineA);
src.a = max(src.a, outlineA);
}
if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src);
}
}
barrier(); // done reading shared for this chunk before reuse
}
}
// ─── CIRCLES ──────────────────────────────────────────────────────────
{
uint heap = pc.itemBuffers.y;
uint count = pc.itemCounts.y;
bool inClip = inSurface &&
((pc.misc.w & UI_FUSED_CLIP_CIRCLES) == 0u || uiPixelInClipRect(pxu, pc.clipCircles));
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
for (uint base = 0u; base < count; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < count) {
s_v0[lid] = uiCircleHeap[heap].items[idx].centerRadius;
s_v1[lid] = uiCircleHeap[heap].items[idx].color;
s_v2[lid] = uiCircleHeap[heap].items[idx].outline;
float radius = s_v0[lid].z;
if (radius > 0.0) {
vec2 cen = s_v0[lid].xy;
vec2 r = vec2(radius + 1.0);
keep = uiAabbOverlapsTile(cen - r, cen + r, tileMin, tileMax);
}
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
perf(ui): subgroup prefix-sum stream compaction, not lane-0 serial scan (#124) uiCompactChunk ran the survivor scan entirely on gl_LocalInvocationIndex == 0: ~64 serial iterations with 63/64 lanes idle, between two barriers, 4x per chunk in the fused kernel. It must emit a stable in-order (buffer-order) exclusive prefix of the survivors so the per-pixel inner loop still sees items in draw order. Replace it with a per-subgroup subgroupExclusiveAdd of the keep bits plus a carry across subgroups: each subgroup publishes its survivor total to shared memory, then every lane sums the totals of all lower-id subgroups for its base. A survivor's slot in s_order[] therefore equals the number of survivors with a smaller local index — buffer (draw) order preserved exactly, unlike an atomicAdd which would scramble it. The carry makes the result correct for any subgroup width (2 subgroups on the 32-wide descriptor_heap target, up to 8/16 on narrower parts), relying only on the gl_LocalInvocationIndex <-> (gl_SubgroupID, gl_SubgroupInvocationID) linear mapping every Vulkan compute implementation provides. The feature is free at the device baseline (apiVersion 1.4 + VK_EXT_descriptor_heap implies Vulkan 1.1 subgroup arithmetic), so no correctness fallback is needed; WebGPU is unaffected (separate embedded WGSL). Applied to ui-fused.comp.glsl and the same pattern inlined in ui-quads/circles/images/text. Verified: all 23 tests pass (UIFusedShader recompiles + spirv-val + pins push-constant offsets); a 140k-trial CPU simulation of the algorithm matches the serial reference for subgroup sizes 1..64; HelloUI renders correctly on an RTX 4090 (DispatchFused quads+circles+text) with draw order intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:26:37 +00:00
uiCompactChunk();
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
barrier();
if (inClip) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 center = s_v0[c].xy;
float radius = s_v0[c].z;
if (radius <= 0.0) continue;
if (abs(sp.x - center.x) > radius + 1.0) continue;
if (abs(sp.y - center.y) > radius + 1.0) continue;
float d = length(sp - center) - radius;
vec4 outline = s_v2[c];
float bodyA = clamp(0.5 - d, 0.0, 1.0);
if (bodyA <= 0.0 && outline.x <= 0.0) continue;
vec4 col = s_v1[c];
vec4 src = vec4(col.rgb, col.a * bodyA);
if (outline.x > 0.0) {
float t = abs(d + outline.x * 0.5) - outline.x * 0.5;
float outlineA = clamp(0.5 - t, 0.0, 1.0);
src.rgb = mix(src.rgb, outline.yzw, outlineA);
src.a = max(src.a, outlineA);
}
if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src);
}
}
barrier();
}
}
// ─── IMAGES ───────────────────────────────────────────────────────────
{
uint heap = pc.itemBuffers.z;
uint count = pc.itemCounts.z;
bool inClip = inSurface &&
((pc.misc.w & UI_FUSED_CLIP_IMAGES) == 0u || uiPixelInClipRect(pxu, pc.clipImages));
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
for (uint base = 0u; base < count; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < count) {
s_v0[lid] = uiImageHeap[heap].items[idx].rect;
s_v1[lid] = uiImageHeap[heap].items[idx].uv;
s_v2[lid] = uiImageHeap[heap].items[idx].tint;
s_v4[lid] = uiImageHeap[heap].items[idx].slots;
s_inv[lid] = 1.0 / s_v0[lid].zw;
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw,
tileMin, tileMax);
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
perf(ui): subgroup prefix-sum stream compaction, not lane-0 serial scan (#124) uiCompactChunk ran the survivor scan entirely on gl_LocalInvocationIndex == 0: ~64 serial iterations with 63/64 lanes idle, between two barriers, 4x per chunk in the fused kernel. It must emit a stable in-order (buffer-order) exclusive prefix of the survivors so the per-pixel inner loop still sees items in draw order. Replace it with a per-subgroup subgroupExclusiveAdd of the keep bits plus a carry across subgroups: each subgroup publishes its survivor total to shared memory, then every lane sums the totals of all lower-id subgroups for its base. A survivor's slot in s_order[] therefore equals the number of survivors with a smaller local index — buffer (draw) order preserved exactly, unlike an atomicAdd which would scramble it. The carry makes the result correct for any subgroup width (2 subgroups on the 32-wide descriptor_heap target, up to 8/16 on narrower parts), relying only on the gl_LocalInvocationIndex <-> (gl_SubgroupID, gl_SubgroupInvocationID) linear mapping every Vulkan compute implementation provides. The feature is free at the device baseline (apiVersion 1.4 + VK_EXT_descriptor_heap implies Vulkan 1.1 subgroup arithmetic), so no correctness fallback is needed; WebGPU is unaffected (separate embedded WGSL). Applied to ui-fused.comp.glsl and the same pattern inlined in ui-quads/circles/images/text. Verified: all 23 tests pass (UIFusedShader recompiles + spirv-val + pins push-constant offsets); a 140k-trial CPU simulation of the algorithm matches the serial reference for subgroup sizes 1..64; HelloUI renders correctly on an RTX 4090 (DispatchFused quads+circles+text) with draw order intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:26:37 +00:00
uiCompactChunk();
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
barrier();
if (inClip) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 lo = s_v0[c].xy;
vec2 hi = s_v0[c].xy + s_v0[c].zw;
if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - s_v0[c].xy) * s_inv[c];
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
vec2 uv = mix(s_v1[c].xy, s_v1[c].zw, t);
uint texSlot = s_v4[c].x;
uint sampSlot = s_v4[c].y;
vec4 sampled = texture(
sampler2D(uiTextures[nonuniformEXT(texSlot)],
uiSamplers[nonuniformEXT(sampSlot)]),
uv
);
vec4 src = sampled * s_v2[c];
if (src.a <= 0.0) continue;
dst = uiBlendOver(dst, src);
}
}
barrier();
}
}
// ─── TEXT ─────────────────────────────────────────────────────────────
{
uint heap = pc.itemBuffers.w;
uint count = pc.itemCounts.w;
bool inClip = inSurface &&
((pc.misc.w & UI_FUSED_CLIP_TEXT) == 0u || uiPixelInClipRect(pxu, pc.clipText));
fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel Shaders - ui-text and ui-fused turned a sample of the R8_UNORM atlas into a distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each byte, so an atlas pixel is kPixelDistScale / 255 of a sample; 32 made every distance 1024/255 (about 4) times too large, and the coverage ramp, meant to span one screen pixel, spanned a quarter of one: nearly every edge pixel came out fully on or fully off, and text looked pixelated at every size. On an RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels where stb_truetype's own rasterizer has 415. Wrong since the v3 UI (1f56973), and copied into the fused kernel (574242d). - The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a glyph's ramp when it is loaded and uiGlyphCoverage the coverage per pixel. Both kernels call them, and a copy of either should; the UI_ prefix keeps them clear of a copied kernel's own names. - stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded value and the decode takes its middle: decoding b pulled every outline 1/64 atlas pixel inwards. - The ramp is capped at what a byte holds, about 4 atlas pixels each way of the outline. With the corrected decode a wider one left every glyph quad's padding translucent below 4 px. - The UV span is taken with abs(): a glyph turned by swapping its UVs got a negative scale, a ramp of 0.0001 and hard edges. - A glyph scaled differently along x and y ramps along the distance's gradient, from two one-texel differences. With the corrected decode, one width for both axes, the more minified one's, would leave a row stretched three times with stems three pixels soft. Glyphs scaled alike, as ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal worked out with the glyph. - The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded as 1024. WebGPU - The WGSL text kernel decoded the distance right but ramped over one atlas pixel whatever the size: half a screen pixel at 16 px, two at 64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient path, and renders TextAntialiasing's scene as Vulkan does (run through Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels, where the old kernel gave 0.55, 2.19 and 0.89). FontAtlas - kOnEdgeValue and kPixelDistScale say the kernels carry copies of them. Tests - TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched three times, a 'g' turned by its UVs and empty atlas texels shrunk to 12.8 atlas pixels per screen pixel, through DispatchText and again through DispatchFused, and holds them to stb_truetype's antialiased rasterizer, to the mirror image and to nothing. On master the two plain rows have 0.31 and 0.21 times the reference's edge pixels; with the decode fixed alone the stretched row had 1.62 times, the turned 'g' was 127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07 and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor sets. It skips a swapchain that isn't 8-bit, which the capture can't read, and captures the first frame. - FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their copies of the two constants to FontAtlas's. No GPU. Docs - AGENTS.consumer.md has a custom text shader decode through the helpers and gives the encoding; README says so in a line; AGENTS.md lists the tests and where the decode lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:22:12 +01:00
// Queried only with text to draw: without a FontAtlas the font slots
// name nothing.
vec2 atlasSize = vec2(1.0);
if (count != 0u) atlasSize = uiGlyphAtlasSize(pc.misc.y, pc.misc.z);
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
for (uint base = 0u; base < count; base += UI_CHUNK) {
uint idx = base + lid;
bool keep = false;
if (idx < count) {
s_v0[lid] = uiGlyphHeap[heap].items[idx].rect;
s_v1[lid] = uiGlyphHeap[heap].items[idx].uv;
s_v2[lid] = uiGlyphHeap[heap].items[idx].color;
s_inv[lid] = 1.0 / s_v0[lid].zw;
fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel Shaders - ui-text and ui-fused turned a sample of the R8_UNORM atlas into a distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each byte, so an atlas pixel is kPixelDistScale / 255 of a sample; 32 made every distance 1024/255 (about 4) times too large, and the coverage ramp, meant to span one screen pixel, spanned a quarter of one: nearly every edge pixel came out fully on or fully off, and text looked pixelated at every size. On an RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels where stb_truetype's own rasterizer has 415. Wrong since the v3 UI (1f56973), and copied into the fused kernel (574242d). - The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a glyph's ramp when it is loaded and uiGlyphCoverage the coverage per pixel. Both kernels call them, and a copy of either should; the UI_ prefix keeps them clear of a copied kernel's own names. - stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded value and the decode takes its middle: decoding b pulled every outline 1/64 atlas pixel inwards. - The ramp is capped at what a byte holds, about 4 atlas pixels each way of the outline. With the corrected decode a wider one left every glyph quad's padding translucent below 4 px. - The UV span is taken with abs(): a glyph turned by swapping its UVs got a negative scale, a ramp of 0.0001 and hard edges. - A glyph scaled differently along x and y ramps along the distance's gradient, from two one-texel differences. With the corrected decode, one width for both axes, the more minified one's, would leave a row stretched three times with stems three pixels soft. Glyphs scaled alike, as ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal worked out with the glyph. - The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded as 1024. WebGPU - The WGSL text kernel decoded the distance right but ramped over one atlas pixel whatever the size: half a screen pixel at 16 px, two at 64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient path, and renders TextAntialiasing's scene as Vulkan does (run through Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels, where the old kernel gave 0.55, 2.19 and 0.89). FontAtlas - kOnEdgeValue and kPixelDistScale say the kernels carry copies of them. Tests - TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched three times, a 'g' turned by its UVs and empty atlas texels shrunk to 12.8 atlas pixels per screen pixel, through DispatchText and again through DispatchFused, and holds them to stb_truetype's antialiased rasterizer, to the mirror image and to nothing. On master the two plain rows have 0.31 and 0.21 times the reference's edge pixels; with the decode fixed alone the stretched row had 1.62 times, the turned 'g' was 127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07 and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor sets. It skips a swapchain that isn't 8-bit, which the capture can't read, and captures the first frame. - FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their copies of the two constants to FontAtlas's. No GPU. Docs - AGENTS.consumer.md has a custom text shader decode through the helpers and gives the encoding; README says so in a line; AGENTS.md lists the tests and where the decode lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:22:12 +01:00
s_ramp[lid] = uiGlyphRamp(s_v1[lid], s_inv[lid], atlasSize);
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
keep = uiAabbOverlapsTile(s_v0[lid].xy, s_v0[lid].xy + s_v0[lid].zw,
tileMin, tileMax);
}
s_keep[lid] = keep ? 1u : 0u;
barrier();
perf(ui): subgroup prefix-sum stream compaction, not lane-0 serial scan (#124) uiCompactChunk ran the survivor scan entirely on gl_LocalInvocationIndex == 0: ~64 serial iterations with 63/64 lanes idle, between two barriers, 4x per chunk in the fused kernel. It must emit a stable in-order (buffer-order) exclusive prefix of the survivors so the per-pixel inner loop still sees items in draw order. Replace it with a per-subgroup subgroupExclusiveAdd of the keep bits plus a carry across subgroups: each subgroup publishes its survivor total to shared memory, then every lane sums the totals of all lower-id subgroups for its base. A survivor's slot in s_order[] therefore equals the number of survivors with a smaller local index — buffer (draw) order preserved exactly, unlike an atomicAdd which would scramble it. The carry makes the result correct for any subgroup width (2 subgroups on the 32-wide descriptor_heap target, up to 8/16 on narrower parts), relying only on the gl_LocalInvocationIndex <-> (gl_SubgroupID, gl_SubgroupInvocationID) linear mapping every Vulkan compute implementation provides. The feature is free at the device baseline (apiVersion 1.4 + VK_EXT_descriptor_heap implies Vulkan 1.1 subgroup arithmetic), so no correctness fallback is needed; WebGPU is unaffected (separate embedded WGSL). Applied to ui-fused.comp.glsl and the same pattern inlined in ui-quads/circles/images/text. Verified: all 23 tests pass (UIFusedShader recompiles + spirv-val + pins push-constant offsets); a 140k-trial CPU simulation of the algorithm matches the serial reference for subgroup sizes 1..64; HelloUI renders correctly on an RTX 4090 (DispatchFused quads+circles+text) with draw order intact. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:26:37 +00:00
uiCompactChunk();
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
barrier();
if (inClip) {
for (uint j = 0u; j < s_count; ++j) {
uint c = s_order[j];
vec2 lo = s_v0[c].xy;
vec2 hi = s_v0[c].xy + s_v0[c].zw;
if (sp.x < lo.x || sp.y < lo.y) continue;
if (sp.x >= hi.x || sp.y >= hi.y) continue;
vec2 t = (sp - s_v0[c].xy) * s_inv[c];
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
vec2 uv = mix(s_v1[c].xy, s_v1[c].zw, t);
fix(ui): decode the font atlas's SDF in atlas pixels, so text edges ramp over one screen pixel Shaders - ui-text and ui-fused turned a sample of the R8_UNORM atlas into a distance with (128/255 - sdf) * 32. stb_truetype writes kOnEdgeValue + kPixelDistScale * distance into each byte, so an atlas pixel is kPixelDistScale / 255 of a sample; 32 made every distance 1024/255 (about 4) times too large, and the coverage ramp, meant to span one screen pixel, spanned a quarter of one: nearly every edge pixel came out fully on or fully off, and text looked pixelated at every size. On an RTX 4090, UIFallbackParity's 22 px label had 98 partly covered pixels where stb_truetype's own rasterizer has 415. Wrong since the v3 UI (1f56973), and copied into the fused kernel (574242d). - The decode now lives once, in ui-shared.glsl: UI_SDF_ON_EDGE and UI_SDF_PIXEL_DIST_SCALE are FontAtlas's two constants in the encoder's own units, uiSdfDistance decodes a sample, uiGlyphRamp works out a glyph's ramp when it is loaded and uiGlyphCoverage the coverage per pixel. Both kernels call them, and a copy of either should; the UI_ prefix keeps them clear of a copied kernel's own names. - stb_truetype truncates, so a byte b stands for [b, b + 1) of the encoded value and the decode takes its middle: decoding b pulled every outline 1/64 atlas pixel inwards. - The ramp is capped at what a byte holds, about 4 atlas pixels each way of the outline. With the corrected decode a wider one left every glyph quad's padding translucent below 4 px. - The UV span is taken with abs(): a glyph turned by swapping its UVs got a negative scale, a ramp of 0.0001 and hard edges. - A glyph scaled differently along x and y ramps along the distance's gradient, from two one-texel differences. With the corrected decode, one width for both axes, the more minified one's, would leave a row stretched three times with stems three pixels soft. Glyphs scaled alike, as ShapeText's are, keep one multiply-add per pixel, the ramp's reciprocal worked out with the glyph. - The atlas size is queried rather than FontAtlas::kAtlasSize hard-coded as 1024. WebGPU - The WGSL text kernel decoded the distance right but ramped over one atlas pixel whatever the size: half a screen pixel at 16 px, two at 64 px. It now repeats ui-shared.glsl's decode, cap, abs and gradient path, and renders TextAntialiasing's scene as Vulkan does (run through Deno's WebGPU: 1.02, 1.07 and 1.09 times the reference's edge pixels, where the old kernel gave 0.55, 2.19 and 0.89). FontAtlas - kOnEdgeValue and kPixelDistScale say the kernels carry copies of them. Tests - TextAntialiasing draws one string at 14 px, 64 px and 14 px stretched three times, a 'g' turned by its UVs and empty atlas texels shrunk to 12.8 atlas pixels per screen pixel, through DispatchText and again through DispatchFused, and holds them to stb_truetype's antialiased rasterizer, to the mirror image and to nothing. On master the two plain rows have 0.31 and 0.21 times the reference's edge pixels; with the decode fixed alone the stretched row had 1.62 times, the turned 'g' was 127 codes off its mirror and the empty texels drew 48. Now: 1.02, 1.07 and 1.09 times, 0 and 0, on the 4090, on llvmpipe and on descriptor sets. It skips a swapchain that isn't 8-bit, which the capture can't read, and captures the first frame. - FontAtlasDecode reads ui-shared.glsl and dom-webgpu.js and holds their copies of the two constants to FontAtlas's. No GPU. Docs - AGENTS.consumer.md has a custom text shader decode through the helpers and gives the encoding; README says so in a line; AGENTS.md lists the tests and where the decode lives. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 20:22:12 +01:00
// Font slots are push constants, dynamically uniform as
// uiGlyphCoverage needs (same as ui-text.comp.glsl).
float a = uiGlyphCoverage(pc.misc.y, pc.misc.z, uv, s_ramp[c], atlasSize);
perf(ui): add additive DispatchFused to collapse consecutive UI passes (#47) Add a fifth UI compute path — DispatchFused — alongside the four per-element Dispatch* calls. It composites quads → circles → images → text (canonical back-to-front order) in ONE dispatch that loads the destination image once and stores once, eliminating the per-pass load+store and the inter-pass VkMemoryBarriers a run of consecutive Dispatch* calls would pay. The win materialises at >= 2 non-empty categories; an absent category is a zero-trip, push-constant-uniform loop (~free). The new uber-kernel (shaders/ui-fused.comp.glsl) reuses each category's exact cooperative shared-memory tile-cull + per-pixel accumulate, so a fused category is pixel-identical to its standalone pass. Categories run sequentially per thread and share one set of shared-memory scratch, so the VGPR/shared high-water mark stays at the per-category level, not the sum. Additive and non-breaking: the per-element Dispatch* calls and the frozen 48-byte UIDispatchHeader are untouched. DispatchFused gets its own 128-byte push-constant layout (UIFusedHeader: four uvec4 headers + four per-category clip vec4s). To interleave a custom ui.Dispatch() between standard passes, bracket it with two DispatchFused calls — the dispatch boundary is the explicit, app-declared flush point. WebGPU (Vulkan-second): DispatchFused falls back to the per-element Dispatch* calls in canonical order — one texture/sampler per dispatch there can't feed the bindless image phase — so the result matches; only the load/store fusion is Vulkan-only. HelloUI now composites its background quads, mouse circle, and label text in a single DispatchFused. Verified rendering identical on a live Vulkan/Wayland session. Resolves #47 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:03:24 +00:00
if (a <= 0.0) continue;
vec4 col = s_v2[c];
vec4 src = vec4(col.rgb, col.a * a);
dst = uiBlendOver(dst, src);
}
}
barrier();
}
}
if (inSurface) imageStore(uiImages[pc.misc.x], screenPx, dst);
}