Commit graph Crafter.Graphics/project.cpp
Author SHA1 Message Date
234fcce099
fix(window): size the swapchain for its present mode, and key frames in flight by frame instead of image
Window
- CreateSwapchain asked for exactly numFrames (3) images whatever the
  surface said, read them into a three-element array, and keyed the
  command buffers, the fences and the descriptor-heap copy holding "its"
  swapchain image by the acquired image index. A swapchain of another
  size fell back to FIFO (asked for MAILBOX or IMMEDIATE) or threw, and a
  surface whose minimum is above three got a create info below it
  (#160). Mesa's MAILBOX needs four images: Turnip on phoc, llvmpipe on
  any Wayland compositor.
- The present mode is picked first, then the swapchain is sized by
  SwapchainCapabilities: the surface's counts for that mode where the
  instance has surface_maintenance1. With swapchain_maintenance1 the
  swapchain names its mode (VkSwapchainPresentModesCreateInfoKHR), which
  is what makes the mode's own count the one that applies, at creation and
  to the forward progress of a UINT64_MAX acquire; without it the stricter
  of the mode's and the surface's counts for all modes. It asks for
  numFrames or that minimum where more, never past the maximum
  (SwapchainMinImageCount). Turnip's FIFO keeps its three images and
  MAILBOX gets four. The FIFO fallback and presentModeUnfit are gone.
- images, imageViewInfos (was imageViews: they are create-infos), the made
  imageViews that descriptor sets take, imageInitialised and the
  render-finished semaphores are per image, as many as the driver made.
  The views are made once per swapchain. The semaphores only grow, since
  a present of the old swapchain may still wait on one.
- Frames in flight stay numFrames, keyed by currentFrame, frameCounter %
  numFrames: the command buffer, the fence, the acquire semaphore, the
  heap copy (or set), RenderingElement3D::tlases, the frameIdx a pass is
  recorded with. currentImage is the acquired image. currentBuffer is
  gone, so code that used it stops compiling instead of picking the wrong
  one; RenderPass::Record's frameIdx keeps its name and is now the frame
  slot, as RenderPass.cppm says.
- The frame's fence is waited before the acquire. The acquire semaphores
  need no spare then, and a frame waits for exactly the one numFrames
  before it, which the deletion queue and the staging rings count on and
  fences keyed by image did not promise once images come back out of
  order. The swapchain slots are written before the fence is reset.
- From the acquire to the present the window is recording: a Resize or
  SetPresentMode from a listener (a VSync key ticked in onUpdate) takes
  effect after the present, through RebuildSwapchain, together with a
  present that found the swapchain out of date. Rebuilding mid-frame used
  to destroy the image the frame was drawing into; with images sized per
  swapchain it would also index past the new one's end.

DescriptorHeapVulkan
- Frames and images pair up differently from frame to frame, so no heap
  copy can keep one swapchain image. AddSwapchainSlot(slot, window) lists
  an image slot as that window's swapchain image, and Window::Render
  writes the image a frame acquired into its window's listed slots in that
  frame's copy, or its set, before onUpdate, skipping a slot that holds it
  already. A slot is one window's, so two windows can share a heap without
  writing each other's slots under each other's frames in flight. A slot
  listed during a frame its window records is written at once. A slot past
  the heap's image slots, an empty ImageSlot's 0xFFFF among them, is
  refused. Freeing a slot unlists it, as does initialising the heap in
  either binding model. WriteImageSet, the one use of which this
  replaces, is gone, and with it the per-frame dimension of setImageViews.

Device
- VK_KHR_surface_maintenance1 (or EXT) and VK_KHR_get_surface_capabilities2
  are enabled where the loader has them (surfaceMaintenance1Supported,
  vkGetPhysicalDeviceSurfaceCapabilities2KHR), and the matching
  swapchain_maintenance1 with its feature where the device has it
  (swapchainMaintenance1Supported).
- DistinctGpus groups the physical devices by GPU as CRAFTER_GRAPHICS_DEVICE
  numbers them, for ChooseGpu and tests.

UI
- UIRenderer lists its output slot instead of writing every copy itself,
  and again on every resize; the resize listener is gone on both
  backends. The headers' frameIdx is currentFrame.

Examples
- VulkanTriangle, Sponza, SponzaBench and RTVolume list their output slot
  and write only the per-frame TLASes. They wrote imageViews[f] into copy
  f once, which a resize left pointing at destroyed images.

Tests
- SwapchainImageCount: SwapchainMinImageCount on the numbers drivers
  report; every offered mode keeps its mode and gets what the window asks
  for; every frame presents the colour it drew, read back a texel per
  frame; a switch and a resize asked for during a frame wait for its
  present; a slot listed during a frame holds its image at once; two
  windows on one heap draw their own colours. The frame checks run again
  in child processes, through CRAFTER_GRAPHICS_DEVICE, on a GPU whose
  swapchains have more images than frames (this one, else llvmpipe), in
  each binding model the parent didn't cover; a child skips only for a GPU
  that can't run the library, and checks it runs the model it was asked
  for.
- FrameLoopSync checks the frame slot follows the frame counter and counts
  the swapchain's own images. HeapASRead, HeapDynamicIndexHit and
  RTPassPushData list their swapchain slot and drop their resize rewrites.

Docs
- README, AGENTS.md and AGENTS.consumer.md: frames and images, the
  swapchain slots, rebuilds held to the present, the new optional groups.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 18:14:18 +01:00
852ae8abe3
fix(descriptor-heap): round the buffer region up, align the reserved range for buffers too, and never size a heap at zero
DescriptorHeapVulkan
- The buffer region started at images * imageDescriptorSize /
  bufferDescriptorSize, rounded down. A descriptor_heap array indexes
  the heap from byte 0 in strides of its descriptor size, so buffer slot
  0 has to sit on a whole buffer descriptor past the images. Where the
  image descriptors were not a whole number of buffer descriptors, the
  first buffer descriptors overwrote the last image descriptors. Where
  they were less than one, the start was bumped to descriptor 1, but the
  heap was still sized as if the buffers began right after the images,
  so the last buffer descriptors went into the implementation's reserved
  range. Every descriptor size is a power of two, so both cases need
  image descriptors smaller than buffer descriptors, which NVIDIA (32
  and 16 bytes) and llvmpipe (64 and 16) do not have. The start now
  rounds up and the heap is sized from it (#163).
- The reserved range sat at a multiple of imageDescriptorAlignment only,
  where vkCmdBindResourceHeapEXT also wants bufferDescriptorAlignment
  (VUID-vkCmdBindResourceHeapEXT-pBindInfo-11435 and -11436). It now
  starts on a multiple of the larger. The buffer descriptors need no more
  than the rounding: each alignment is at most its descriptor size, so a
  whole number of descriptors is aligned.
- bufferStartElement is 16 bits and took the division unchecked, so an
  image region past 65535 buffer descriptors (16384 images on llvmpipe)
  wrapped the buffer region around onto the images. Slot counts whose
  buffer indices do not fit 16 bits, 0xFFFF being the empty slot, now
  throw, naming the counts. A heap without buffer slots has no buffer
  region, which starts at 0 as in descriptor-set mode, so image-only
  heaps have no index to fit.
- On a driver that reserves nothing (llvmpipe), a heap without sampler
  slots was a zero-size buffer, which every Initialize(1, 1, 0) made,
  and Window::Render bound its address each frame. Each heap now holds at
  least one descriptor, as each binding does in descriptor-set mode. That
  grows NVIDIA's sampler heap by one descriptor when no sampler slots are
  asked for; the resource heaps of the slot counts in the tree keep their
  layouts byte for byte.
- ComputeLayout holds the arithmetic, pure over the descriptor-heap
  properties, and Initialize goes through it. GetBufferOffset and
  GetBufferOffsetElement, which had their own copies of the old
  arithmetic and never had a caller, are gone.
- Bind(cmd, frame) binds a frame's heaps with the reserved ranges
  Initialize stored, or in descriptor-set mode the frame's descriptor
  set. Window::Render and the RTVolume example worked the reserved-range
  offset out from the heap's size with the image-alignment mask, and
  call Bind now. The window's per-heap cache of bind structs goes with
  it: keyed on the heap pointer, it kept binding the old buffers after a
  re-Initialize that reallocated them. The heap still ends with its
  reserved range, so code that derives the offset from the size keeps
  getting the right one.

Tests
- DescriptorHeapLayout: the issue's two layouts, the reserved range where
  buffer descriptors are aligned more strictly than image ones, every
  legal combination of descriptor sizes and alignments up to 256 bytes,
  no heap empty, the NVIDIA and llvmpipe layouts, the 16-bit limit and
  image-only heaps. Pure CPU, no device. Against the old arithmetic it
  fails 12 of its 18 checks.

Docs
- README.md's heap bullet and AGENTS.consumer.md say how to bind the
  heap outside the frame loop, and the consumer notes that Initialize
  throws when buffer indices would pass 16 bits. AGENTS.md lists
  ComputeLayout among the functions the pure-logic tests drive.

Closes #163.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 16:06:44 +01:00
670df31cc6
fix(wayland): open windows on compositors without xdg-decoration, viewporter or fractional scale
Window
- The constructor made a fractional scale, a toplevel decoration and a
  viewport from Device::fractionalScaleManager, Device::manager and
  Device::wpViewporter without looking at them. Device::handle_global
  binds those globals only when the compositor advertises them, and
  Initialize requires only wl_shm, wl_compositor and xdg_wm_base, so on a
  compositor without one of the three (GNOME's Mutter has no
  xdg-decoration) the constructor handed libwayland a null proxy and
  crashed before the window existed (#162). Each object is now made only
  when its global is there, which Resize and the destructor already
  assumed.
- The fractional scale needs the viewporter too: the preferred scale is
  applied through the viewport, which shows the buffer's physical pixels at
  width/scale x height/scale. A surface without a viewport is as large as
  its buffer, so there the scale stays 1, which keeps pointer and touch
  positions, configure sizes and the text-input caret, all converted by
  it, in physical pixels; the compositor scales the window up on a HiDPI
  output. A scale that changes after the constructor is still applied only
  by the next resize, which is #165.
- Without xdg-decoration the window has no title bar, since the library
  draws no client-side decorations.

Device
- The optional globals say they are optional, and that a request on a
  null proxy crashes.

Tests
- WindowOptionalGlobals: hides xdg-decoration, wp_viewporter and
  wp_fractional_scale_v1 from the Window one at a time and all at once,
  by nulling the Device statics after Initialize, and renders, resizes
  and closes a window each time without a protocol error. Before the fix
  it crashed on the window without xdg-decoration. A global the compositor
  does not offer is reported as a SKIP line, since the path that uses it
  goes untested there.

README
- Which Wayland protocols the window needs, and what it does without the
  optional ones.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 09:59:13 +01:00
6e94cc6209
fix(rt): split Sponza's combined image sampler into a heap texture and sampler
examples/Sponza no longer built natively (#164). Its closest-hit declared a
descriptor_heap sampler2D, which glslang has rejected since c8253623
("Reject descriptor_heap combined image samplers"), and main.cpp wrote it
through VkResourceDescriptorDataEXT::pCombinedImageSampler, which that union
no longer has: the descriptor heap has no combined image samplers. Both went
unnoticed because nothing builds the examples.

Sponza
- closesthit.glsl declares texture2D and sampler heap arrays and joins them
  where it samples, as ui-shared.glsl and HeapDynamicIndexHit do. The
  sampler slot is a second spec constant beside the image slot, so the
  shader assumes nothing about where the host put it, and a constant index
  keeps the example running on NVIDIA 610.43.02 (see its README). The
  comment no longer says the image slot is offset by bufferStartElement;
  only buffer slots are.
- main.cpp gives the heap a sampler slot, writes the albedo as a
  SAMPLED_IMAGE from a view create-info, and writes the sampler into the
  sampler heap from a VkSamplerCreateInfo with vkWriteSamplerDescriptorsEXT,
  as UIRenderer::RegisterSampler does. SamplerVulkan's VkSampler had no use
  left. The sampler keeps its linear filtering and repeat addressing and
  drops anisotropy, which a hit shader cannot use: it samples at LOD 0,
  without derivatives.

Docs
- README.md and examples/Sponza/README.md quote the new declarations, and
  the Sponza README says why the texture and the sampler are two
  descriptors. Its texture2DArray materials pattern declared a
  descriptor_heap sampler2DArray, which glslang rejects as well; it now
  joins a texture2DArray and a sampler.
- examples/SponzaBench/README.md drops its caveat that Sponza does not
  compile.

Tests
- ExampleShaders compiles every GLSL shader under examples/ with glslang as
  crafter-build does for the example (Vulkan rules, SPIR-V 1.4, shaders/ on
  the include path, and the descriptor-set build where the example
  registers one), so a glslang that starts rejecting one fails a test
  rather than an example build nobody runs. It fails on Sponza's old
  closest-hit and needs glslang only.

Closes #164.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 08:25:52 +01:00
bcdc885de8
feat(device): applications ask for the device features their shaders use
Device
- Device::Initialize(required, optional) takes DeviceFeatures, the five
  core feature structs vulkan10 to vulkan14. The library enables only what
  its own shaders and code use. Device::enabledFeatures says what was
  enabled, supportedFeatures what the GPU has, apiVersion the version in
  use.
- shaderInt16, shaderFloat16, storageBuffer16BitAccess, scalarBlockLayout
  and the uniform-buffer, storage-buffer and storage-image non-uniform
  indexing bits are no longer enabled for everyone. An application whose
  shaders take uint16_t spec constants passes
  {.vulkan10 = {.shaderInt16 = VK_TRUE}}. scalarBlockLayout stays on with
  the NVIDIA workaround, whose TLAS address may sit in a struct's tail
  padding.
- Every GPU is weighed before one is chosen: Vulkan 1.3, the swapchain and,
  below 1.4, maintenance5 extensions, the library's needs, the required
  features. One that can't run the application is skipped, saying why when
  it ranks higher. A CPU device runs it only when nothing else is listed,
  and CRAFTER_GRAPHICS_DEVICE never falls back. A GPU two drivers both list
  is tried through each.
- When no GPU qualifies, Initialize throws naming each GPU's gaps. It
  destroys the instance it made, before any Wayland connection, so it can
  be called again asking for less.
- maintenance5 is vulkan14's on a 1.4 GPU and VK_KHR_maintenance5's below.
  CRAFTER_GRAPHICS_NO_VULKAN_1_4 runs as a 1.3 application, to exercise
  that path on a 1.4 GPU.
- VK_KHR_spirv_1_4 and VK_KHR_shader_float_controls, core since 1.2, are no
  longer enabled, nor VK_EXT_shader_atomic_float, which was enabled without
  its feature struct. memoryDecompression's feature bit is queried before
  it is enabled.

Shaders
- VulkanShader checks a module's capabilities, declared directly or
  implicitly, against the enabled core features, the GPU's subgroup
  operations and the optional groups, and throws naming the field to pass,
  or saying the GPU lacks it. The tables are generated from vk.xml and
  SPIR-V's grammar.
- WorkaroundNvidiaAS::Patch sized a row-major matrix push member by its
  columns, so the TLAS address could land inside it.

Window
- The swapchain format is one whose own feature flags allow storage reads
  and writes without a format, which since Vulkan 1.3 each format says for
  itself; shaderStorageImageRead/WriteWithoutFormat are no longer required.

Tests
- DeviceFeatures: ResolveFeatures, ChooseDevice and CheckShaderCapabilities,
  without a GPU.
- FeatureRequests: required and optional features end to end, the 1.3
  path, and a retry after a refusal.
- PushConstantRewrite: a row-major mat3x4 push member.
- HeapASRead, HeapDynamicIndexHit, RTPassPushData and the ray-tracing
  examples ask for the features their shaders use.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 06:40:02 +01:00
c62109caf3
fix(win32): restore mouse moves, add enter and leave, capture drags, and close and destroy windows cleanly
Win32
- WM_MOUSEMOVE is handled again: c9ebd44 put the WM_CHAR case where it
  was, so currentMousePos stopped following the mouse and onMouseMove
  never fired. Positions are read signed (GET_X_LPARAM), since under
  capture they run past the client area.
- onMouseEnter and onMouseLeave. The first move over the client area
  after a leave is the enter, and arms the TME_LEAVE whose WM_MOUSELEAVE
  is the leave. An enter counts only once that is armed, a leave only
  after an enter, and a move past the client area is a move only.
- A press captures the mouse, so a drag that leaves the window still
  sends its moves and its release, as Wayland's implicit grab does.
  Losing the capture releases what is held, and a button-up without its
  press fires nothing.
- WM_CLOSE fires onClose and clears open, as xdg_toplevel.close does,
  instead of destroying the HWND under the swapchain. WM_DESTROY no
  longer posts WM_QUIT, which nothing read.
- ~Window detaches the HWND and destroys it. It stayed on screen, and its
  messages went to the freed Window. WndProc hands messages without a
  Window to DefWindowProc, and looks the Window up again after an event
  that more work follows, in case a listener destroyed it.
- The constructor's message pump leaves input queued for StartSync: the
  WM_MOUSEMOVE sent when a window appears under the cursor made an
  onMouseEnter nobody could hear yet. The mouse state is reset before
  the platform code, no longer after it.
- A window after the first finds its class registered, which showed an
  error box.
- The wheel remainder is the window's, no longer a function static that
  every window shared, and a leave drops it.

Window
- PointerEnter, PointerMove, PointerLeave and PointerScroll hold the rules
  both native backends report the pointer by. An enter puts the entry
  position in currentMousePos before onMouseEnter fires, and is followed
  by a move, on Wayland too now. scrollRemainder replaces
  Device::scrollDetentRemainder, and mouseInside is shared.

Tests
- PointerEnterLeave: enter, move and leave through the wl_pointer
  callbacks.
- PointerWin32: posts a real HWND the messages above and pumps them as
  StartSync does. Built for x86_64-w64-mingw32 it runs under Wine, and is
  skipped where Wine is not on PATH.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 03:29:50 +01:00
397ff2db51
fix(rt): rebuild TLASes in place, after earlier traces, and queue their handles like a Mesh's
BuildTLAS
- A rebuild for a new instance count or build preference used to create a
  new acceleration structure and destroy the old one at once, though the
  command buffer being recorded could still use it (a scene and then a
  model built in one setup). The acceleration structure now spans its whole
  storage buffer, and a rebuild that fits builds into it again, so the
  handle and tlases[i].address stay the same and nothing is destroyed. A
  TLAS that outgrows its storage gets new storage and a new handle; the old
  handle goes to the deletion queue ahead of the storage it lives in. It is
  queued after the scratch is resized, and its address goes with it, so a
  Resize or create that throws leaves no address of a queued acceleration
  structure behind.
- The barrier before the build also orders earlier traces and compute ray
  queries of the TLAS, which a refit or a rebuild in place would otherwise
  overwrite while they read it (BuildTlasWriteBarrier). Its source access
  mask holds no read bit: one does nothing, but makes synchronization
  validation 1.4.363 report the second of two builds in one command buffer
  as a write-after-write hazard.
- TlasReadBarrier is now TlasBarrier, shared by both barriers.
- Documented: call BuildTLAS once every earlier submission that used
  tlases[index] has completed, and when the address stays the same.

TlasWithBuffer
- Moves hand the acceleration structure over, and destroying or assigning
  over one queues it ahead of its storage. The implicit members dropped the
  handle over freed storage, or left two owners and a moved-from TLAS that
  refit through buffers it no longer had.

Tests
- TLASLifetime: destroying, moving, move-assigning and resetting a
  TlasWithBuffer.
- TLASHighWaterMark skips without ray tracing. It asserts the acceleration
  structure is rebuilt in place on a shrink or a growth within capacity,
  that the old handle is queued before its storage on a growth past it, and
  that builds in one command buffer keep the handle while they fit, queue
  it once when one outgrows the storage, and free it only on its retire
  frame.
- TLASBuildBarrier checks the barrier before the build too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-31 02:32:48 +01:00
079b6b0e1f
feat: windows on their own clock, deferred deletion for every handle owner, staged re-uploads, and RTPass push data
Window
- updateInterval runs Update on the window's own clock while the
  compositor sends no frame callbacks (hidden, minimized): the Wayland loop
  is a poll() loop, and the Win32 loop sleeps until the next update. Both
  read the interval each pass.
- SetPresentMode (FIFO, Mailbox, Immediate). A mode the surface lacks, or
  whose swapchain has other than numFrames images (Mesa's Mailbox has four),
  falls back to FIFO until the mode is set again.
- AddPollFd / RemovePollFd wake the Wayland loop on the app's descriptors.
- The swapchain has TRANSFER_DST where the surface allows it.
- CaptureNextFrame replaces SaveFrame: it records the copy in the frame,
  before the present, where SaveFrame read an image already presented. A
  frame unwound past before the write still frees the readback buffer.
- Inter-pass barriers on the swapchain image carry the access masks of the
  stages the passes use.

Deferred deletion (issue #101)
- VulkanBuffer, Mesh, VulkanShader, ComputeShader and PipelineRTVulkan hand
  their handles to the deletion queue from their destructors and moves, as
  a frame in flight may still use them; a static owner goes after the
  driver has torn down. VulkanShader and PipelineRTVulkan move and can't be
  copied; re-Load and re-Init queue the old pipeline.
- The queue and its mutex are never destroyed, and the queue takes handles
  from any thread; Device::frameCounter is atomic. Both the queue and the
  staging rings count frames as one window renders them (documented).
- DrainDeletions also lets every buffer's staging ring start over: without
  a Window nothing else reclaims either, so an app without one calls it
  after waiting for its work.

Meshes and buffers
- A Mesh rebuild queues the old acceleration structure and builds into the
  same storage after a barrier, so the BLAS address stays put when it fits.
  The refit and rebuild barriers cover compute ray queries too.
- RTBuildOptions::noDuplicateAnyHit. Mesh::geometryFlags replaces opaque,
  and the refit and rebuild fallbacks keep the flags.
- A re-upload into a buffer a frame in flight may read (UploadDeviceLocal
  of the same size, UploadDeviceLocalRange) copies on the GPU, after those
  reads, through a per-frame staging ring; on ReBAR it used to write the
  buffer in place. Empty uploads record nothing.
- A compressed Mesh::Build that keeps its buffers decompresses into them
  after the reads of frames in flight, and the hit shaders and ray queries
  wait for the decompress as well as the BLAS build. Its vertex buffer
  takes a ranged refit's copies.
- Mesh move-assigns (queueing the BLAS it replaces), so it can be replaced
  in place or erased from a vector.
- VulkanBuffer::Create asks for a device address only for buffers with
  SHADER_DEVICE_ADDRESS usage, as vkGetBufferDeviceAddress requires; the
  others have address 0. UIRenderer::RegisterBuffer refuses those.
- Build inputs are synchronized as shader reads.

Other
- RTPass pushes the app's own push data and traces at an explicit size.
- DescriptorHeapVulkan hands freed slot ranges out again (they leaked), and
  keeps its free lists sorted.
- ImageVulkan's mip chain clamps each level to 1 texel: a non-square
  image's shorter side went to 0.
- VulkanShader throws on a missing file instead of failing in bad_alloc.
- CRAFTER_GRAPHICS_DEVICE picks the GPU by its number among the distinct
  GPUs Vulkan lists; anything but such a number is an error.
- The debug messenger prints info messages only with
  CRAFTER_GRAPHICS_VALIDATION set.
- Windows: the WinRT gamepad code moved to a C file (mingw-w64's WinRT
  headers don't compile as C++), -lcombase dropped, a reference leak fixed.
- project.cpp honours --local for Crafter.Event, Math and Asset, as Asset
  does for Math, and builds every test through one helper, projectArgs
  included.
- Tests: DebugMessengerSeverity, DeletionQueueThreads, DeviceSelection,
  HeapSlotReuse, MeshLifetime, MipChainNonSquare, PipelineLifetime,
  RTPassPushData, SwapchainTransferDst, UploadInFlight, VulkanBufferAddress,
  VulkanShaderLifetime, WindowUpdateClock; others extended.

Breaking: SaveFrame is CaptureNextFrame; Mesh::opaque is geometryFlags; a
buffer without SHADER_DEVICE_ADDRESS usage has no address; frameCounter is
std::atomic.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 20:20:29 +02:00
37fe0dc49f
test(rt): pick hit-shader descriptors from the heap by a runtime index
On NVIDIA 610.43.02, selecting a descriptor-heap descriptor with a
runtime index in a hit shader lost the device, for storage buffers and
sampled images alike, which is why the examples use spec constants
there. HeapDynamicIndexHit renders two instances whose closest-hit picks
a buffer and a texture by a runtime index: one instance uses the same
set across every wave, the other a different set in every other pixel
column, so neighbouring lanes select different descriptors. It passes
on 615.78.08 and on llvmpipe.

On NVIDIA drivers older than 615.78.08 it skips rather than abort the
suite. With validation on it also renders in a child process with
validation off, since GPU-assisted validation hides the fault.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 03:03:16 +02:00
918b4b0eb3
test(rt): read the TLAS from the descriptor heap, with a probe for the NVIDIA fault
HeapASRead renders a triangle, tracing half the frame with traceRayEXT
and ray querying the other half, both reading the TLAS out of the
descriptor heap, and checks the read-back pixels. The test runner runs
it through the engine's normal path, so on NVIDIA it covers the rewrite
too: an inherited CRAFTER_GRAPHICS_NO_AS_WORKAROUND is cleared, and the
test checks the rewrite is on exactly where the driver needs it.

HeapASRead --no-workaround is the check for a newer driver. Each path
renders in its own child process with the rewrite off and validation
off (GPU-assisted validation instruments the shaders), so a device loss
on one doesn't hide the other. On 615.78.08 traceRayEXT still loses the
device and the ray query renders nothing.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 03:03:16 +02:00
e5cfbd249c
test(rt): an empty scene's TLAS builds, then grows and shrinks
TLASEmptyBuild builds frame 0's TLAS with no elements registered and
checks it is a real acceleration structure, then adds an instance and
removes it again, rebuilding each time. Before the fix the first build
threw on NVIDIA (VK_ERROR_OUT_OF_DEVICE_MEMORY).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 03:03:16 +02:00
5d25c1f825
test(rt): BuildTLAS's read barrier makes the build visible to TLAS reads
TLASBuildBarrier drives BuildTlasReadBarrier with no GPU device: the
dependency starts at the acceleration-structure build, reaches the
ray-tracing and compute shader stages, and carries the
acceleration-structure write -> read memory dependency that the old
execution-only barrier lacked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 03:03:16 +02:00
dfb70bc088
fix(gamepad): build libevdev from a vendored copy
The Linux gamepad backend included <libevdev/libevdev.h> from
/usr/include/libevdev-1.0 and linked the system libevdev, but the
Flatpak SDK crafter-build runs in (org.freedesktop.Sdk 26.08) ships no
libevdev headers, so Crafter.Graphics stopped building there.
lib/libevdev now carries libevdev 1.14.0 (MIT), built once as its own
static library that Crafter.Graphics depends on. As part of cfg it would
be compiled again in every test configuration, since they copy
cfg.cFiles.

libevdev's generated event-names.h names every input code by its macro,
so the library compiles against the kernel input headers that table was
generated from (lib/libevdev/kernel-headers), not the target's. That
directory is deliberately not called include: Crafter.Build puts every
include directory of a dependency on its consumers' include path, where
those headers would shadow each consumer's own <linux/input.h>. The
paths are absolute because Crafter.Build only resolves the returned
configuration's paths against the project root.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 03:03:16 +02:00
678f38964f
feat(wayland): text input through zwp_text_input_v3
A window can now say that a text field has the focus and where its caret
is — Window::SetTextInput(active, caret rectangle in physical pixels,
purpose) — which is what opens the on-screen keyboard on a phone (Maliit
under KWin, squeekboard under phoc). What the input method produces comes
back as events: committed text as onTextInput, the composing text as
onPreedit (empty clears it), deletions around the cursor as
onTextInputDelete; done() applies them in the protocol's order. The state
a window wants is remembered and sent again whenever the keyboard focus
returns to it. The protocol's client code is generated with wayland-scanner
1.26.0 from wayland-protocols' text-input-unstable-v3.xml, under lib/ like
the others.
2026-10-18 01:57:09 +02:00
2b6f90b12c test(ui): the descriptor-set fallback draws the same frame as the heap
UIFallbackParity renders one scene through both binding models and
compares the frames. It starts itself twice, once as is and once with
CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP, so the fallback cannot drift from
the heap path on a machine that never takes it. The scene reaches what
HelloUI does not: the four standalone kernels next to the fused one, a
sampled image with two samplers indexed per item, an image slot and a
sampler slot that are freed and registered again, a resize, an image
created with ImageVulkan::Create's default consumer stage, and a
generic Dispatch whose push payload is not a whole number of words.

On a driver without the heap both runs take the fallback, and the test
then only checks that the scene renders. Validation errors raised
while rendering fail a run, which only bites when
CRAFTER_GRAPHICS_VALIDATION is set. They are counted before the frame
capture because Window::SaveFrame raises one itself (#157).

UIFusedShader now also compiles and validates the descriptor-set build
of the fused kernel and pins its push-constant layout: both builds are
fed the same UIFusedHeader bytes.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:28:46 +02:00
48aa9be5ce feat(vulkan): run the compute/UI path without VK_EXT_descriptor_heap
Device::Initialize required twelve device extensions with no capability
check. Mesa Turnip (Adreno) lacks six of them: VK_EXT_descriptor_heap,
VK_KHR_shader_untyped_pointers, VK_EXT_device_fault and the three ray
tracing extensions. vkCreateDevice failed there, so not even a UI-only
program could start, although the UI path is compute-only and needs
nothing else that Turnip is missing.

The descriptor heap, ray tracing and device fault are now optional
groups, detected at start-up from the extension list and the feature
bits (Device::descriptorHeapSupported, rayTracingSupported,
deviceFaultSupported). Ray tracing needs the heap; on a device without
it the ray tracing entry points throw a message naming the cause.

Without the heap the same bindless slots live in one update-after-bind
descriptor set per frame, owned by DescriptorHeapVulkan: binding 0
storage images, 1 sampled images, 2 samplers, 3 storage buffers. Slot
allocation, the slot handles, UIDispatchHeader and the UIRenderer API
are unchanged; buffer slots simply stop being offset. Window::Render
binds that frame's set where it binds the heap, every compute pipeline
shares the heap object's pipeline layout, and push data travels as push
constants.

Each UI kernel is built twice from one source. ui-shared.glsl declares
its resources through UI_HEAP_* macros that expand to descriptor_heap
or, with CRAFTER_GRAPHICS_DESCRIPTOR_SETS defined, to set/binding.
<name>.comp.spv and <name>.comp.sets.spv ship side by side and
ComputeShader::Load picks one. The heap build of every shader is
byte-identical to before.

Breaking:
- ComputeShader::Load takes the heap as a second argument.
- DescriptorHeapVulkan is no longer copyable or movable.
- Custom UI shaders declare heap resources with the UI_HEAP_* macros
  and are registered twice in project.cpp (see the README).
- project.cpp uses Shader::defines and Shader::variant, so it needs a
  crafter-build that has them (Crafter.Build master, fe38cc1 or later).

Also:
- maintenance5 is enabled as a feature, not only as an extension:
  every VulkanBuffer already relied on VkBufferUsageFlags2CreateInfo.
- Barrier stage masks drop RAY_TRACING_SHADER on devices without ray
  tracing, and ImageVulkan::Create resolves its default consumer stage
  to COMPUTE_SHADER there.
- Window refuses a swapchain whose image count is not numFrames
  instead of writing past images[]. The limitation itself is #160.
- CRAFTER_GRAPHICS_NO_DESCRIPTOR_HEAP forces the fallback on a driver
  that has the heap, so it can be exercised on a desktop GPU.

Verified on an RTX 4090 in both modes under the validation layer, and
on a Fairphone 6 (Turnip, Adreno 810, postmarketOS), where the fallback
is the only path.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-10-06 17:28:46 +02:00
6aa0338c0f wasm improvements 2026-08-05 04:08:13 +02:00
42a479572d SPDX license update 2026-07-22 18:09:06 +02:00
a879c834c7 webgpu embedding 2026-07-19 01:38:25 +02:00
catbot
7316e51dca fix(window): silence per-frame and setup-path Vulkan validation errors (#153)
Two distinct validation errors the native frame loop emitted, both
originating in Crafter.Graphics with no consumer-side influence.

Problem 1 — per-frame acquire-barrier access/stage mismatch. The
acquire->GENERAL barrier hardcoded dstAccessMask =
SHADER_WRITE|TRANSFER_WRITE but used the per-pass stage union as its dst
stage mask. For an all-compute frame the union narrows to COMPUTE_SHADER,
which does not support TRANSFER_WRITE, so VUID-02820 fired every frame.
Derive the access mask from the same stage union via a new
SwapchainWriterAccess() helper (mirroring SwapchainStageUnion), and apply
it to both the acquire dst and present src masks for symmetry.

Problem 2 — mid-session StartInit/FinishInit (and GetCmd/EndCmd) reuse the
shared drawCmdBuffers[currentBuffer]. With no steady-state wait-idle the
loop's last submission of that buffer may still be in flight when scene
setup runs (building map meshes / acceleration structures), so the old
code re-began (VUID-00049) and re-submitted (VUID-00071) a pending buffer,
and resources freed in the StartInit..FinishInit bracket could still be
referenced by it. Drain the queue at the start of StartInit/GetCmd before
re-recording; setup is rare, so a wait-idle is fine (FinishInit/EndCmd
already wait-idle at the end).

Tests: extend SwapchainBarrierScope with SwapchainWriterAccess coverage
(pure CPU), and add SetupCmdBufferReuse — a real-frame-loop regression
test driving a compute pass plus interleaved mid-session StartInit rounds,
asserting the validation layer stays silent. Verified both halves fail
(reproducing the exact VUIDs) when their respective fix is reverted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 18:04:25 +00:00
catbot
a45e793da4 perf(ui): memoize the input-field caret prefix width (#128)
DrawInputField re-measured the cursor prefix via Font::GetLineWidth on
every frame of a focused field, even though only the blink (caretVisible)
changes frame-to-frame — value, cursorPos and fontSize are stable across
the vast majority of frames.

Cache the measured prefix WIDTH on the InputField, keyed on (prefix bytes,
fontSize). A cheap byte compare of the cached prefix guards the expensive
per-glyph UTF-8 decode + advance accumulation. The cache is mutable so the
const-ref draw fn can refresh it.

The absolute caretX is deliberately NOT cached: it adds the layout-
dependent textX (rect.x + paddingX) each frame, so caching it would give a
relocated field a stale caret. Caching the width keeps that correct.

Adds tests/InputFieldCaretCache pinning: the cached caret matches the
uncached GetLineWidth oracle across states, a redraw (cache hit) is
identical to the miss, the cache invalidates on value/cursorPos/fontSize
changes, the blink never moves the caret, and a relocated field shifts the
caret by exactly the rect delta.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 13:29:55 +00:00
2783e47674 Merge pull request 'perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#118)' (#140) from claude/issue-118 into master 2026-06-18 15:07:44 +02:00
catbot
b5b8c04237 perf(tlas): dirty-track the per-frame TLAS instance+metadata upload (#118)
BuildTLAS rebuilt the host instance+metadata buffers with an O(n) copy of
every 64 B VkAccelerationStructureInstanceKHR + metadata entry every frame,
unconditionally, then flushed the whole high-water capacity (VK_WHOLE_SIZE)
on both buffers. At the millions-of-instances target that copy dominates the
CPU frame, and the whole-buffer flush costs on non-coherent BAR/VRAM.

Add a generation counter so only changed host-authored fields are copied, and
feed the same dirty span into the ranged FlushDevice(offset, bytes) overload:

- RenderingElement3D::hostDataVersion + MarkHostDataDirty() (bumps a global
  monotonic counter). TlasWithBuffer::uploadedVersion records, per frame, the
  version last copied into each slot. A slot is copied only when its element
  advanced past the recorded version; version 0 ("untracked") reads dirty every
  frame, so callers that don't opt in keep the prior copy-every-frame behaviour.
  Globally-unique versions make this correct under relocation (Remove's
  swap-and-pop, and remove+add that nets the same count on the refit path)
  without tracking element identity. The reset on every topology change covers
  buffer reallocation and the reshuffled element->slot mapping.
- The dirty [first, last] envelope drives both the copy and the flush: a new
  VulkanBuffer::FlushDevice(cmd, access, stage, offset, bytes) overload flushes
  + barriers just that span for instanceBuffer, and the ranged
  FlushDevice(offset, bytes) for metadataBuffer. When nothing is dirty both are
  skipped — the skipped HOST->build barrier only ever ordered host writes, never
  the application's compute-written GPU-owned transform (that compute->build
  ordering is the caller's, and is unchanged).

Constraint honoured: transformOwnedByGpu transforms are still never host-copied.
The API field/method are mirrored on the WebGPU class for source portability
(the WebGPU build re-uploads its small mirror wholesale and ignores the version).

New test TLASInstanceDirtyTracking drives the real RT device and reads back the
host-mapped buffers to assert: tracked elements upload once then skip until
re-marked, untracked elements always upload, and relocation on the refit path
re-uploads exactly the moved slots — with zero validation-layer errors over the
ranged flush.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:58:45 +00:00
82fd6916d9 Merge pull request 'perf(sync): scope frame-loop barriers to swapchain image + real per-pass stages (#115)' (#137) from claude/issue-115 into master 2026-06-17 21:45:13 +02:00
catbot
9414863cff perf(sync): scope frame-loop barriers to swapchain image + real per-pass stages (#115)
The inter-pass and acquire/present barriers in the frame loop set both
stage masks to ALL_COMMANDS, and the inter-pass dependency used a
queue-wide VkMemoryBarrier — fully serialising against every pipeline
stage and flushing all caches every frame, when all the next pass needs
is the swapchain image the previous one wrote.

Replace the inter-pass global VkMemoryBarrier with an image memory
barrier scoped to the swapchain image's single colour subresource (as
the intra-pass UI barrier already does), and derive the barrier stage
masks per pass: RenderPass::SwapchainStage() is overridden by UIRenderer
(COMPUTE_SHADER) and RTPass (RAY_TRACING_SHADER), so a compute->compute
edge only serialises COMPUTE while an RT pass pulls in RAY_TRACING — the
acquire/present frame-edge masks use the real union of the frame's
passes (SwapchainStageUnion). The base default and the empty-passes
fallback are the conservative COMPUTE | RAY_TRACING | TRANSFER union, so
a polymorphic or un-overridden pass can only be over- not
under-synchronised.

Adds SwapchainBarrierScope (pure CPU) pinning the per-pass derivation,
the union narrowing, and the inter-pass barrier scope; FrameLoopSync
already drives the real GPU frame loop with validation enabled.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:44:33 +00:00
catbot
b4fa6e596e perf(image): release static texture staging after upload (#114)
ImageVulkan kept its host-visible staging `buffer` (sized w*h, persistently
mapped) alive for the whole life of the image and Destroy() never freed it, so
static textures (e.g. Sponza albedo) pinned HOST_VISIBLE / small-BAR memory
forever.

Update now releases the staging via VulkanBuffer::DeferredClear() right after
recording the buffer→image copy — the same fence-keyed deletion queue
(#101/#102) the compressed Mesh path already uses — so it is freed once the
upload submit's frame has cleared. A new `streamed` flag (set by FontAtlas)
keeps the persistent map for images re-uploaded from a CPU-side staging buffer
every frame; its uploads go through UpdateRegion, which never releases. Destroy
now also frees any staging the image still owns, gated on a live handle so a
released static texture can't double-free.

Adds tests/ImageStagingRelease driving the real upload + readback on a headless
device: asserts the staging is enqueued (not pinned), the image reads back
byte-equal (released staging outlived the submit), the entry retires only after
framesInFlight frames, and a streamed image keeps then frees its staging.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:40:35 +00:00
catbot
b29b8f0371 test(decompress): cover compressed-staging release on the real GPU path
MeshDecompressStagingRelease drives the real VK_EXT_memory_decompression /
GDeflate path on a headless device and asserts the staging is handed to the
deferred-deletion queue (not pinned), the build is validation-clean with
byte-correct decompressed data, and the entry retires only after framesInFlight
frames. Skips the GPU-path assertions when the extension is absent (CPU fallback
never allocates the staging).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 17:40:39 +00:00
catbot
cb012d7068 fix(device): fence-keyed deferred resource-deletion queue (#101)
Since #40 dropped the per-frame vkQueueWaitIdle, frames are pipelined:
a resource the CPU is done with may still be read by the GPU for up to
numFrames-1 more frames. VulkanBuffer::Resize's destroy-and-recreate
path was therefore a live use-after-free (#63), no longer masked by the
wait-idle.

Device gains a monotonic, frame-counter-keyed deletion queue:
EnqueueDeletion tags {buffer, memory} with the current frameCounter;
ReclaimDeletions (called per frame after the fence wait) frees entries
once framesInFlight frames have elapsed; DrainDeletions frees everything
after a wait-idle. VulkanBuffer::DeferredClear hands handles to the queue
and nulls the handle, and Resize uses it instead of immediate Clear().

Window::Render sets framesInFlight at init, reclaims after the per-image
fence wait, bumps Device::frameCounter once per frame, and drains on the
resize / OUT_OF_DATE wait-idle paths. The destructor keeps immediate
Clear() (callers destroying mid-flight remain responsible, unchanged).

Adds the DeferredDeletion test: drives the retire timing on a real
headless device with real buffers, stepping Device::frameCounter to pin
the exact reclaim frame, asserting validation stays silent.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 13:21:12 +00:00
c3d7f52891 Merge pull request 'perf(ui): flush only the written descriptor range, not the whole heap (#61)' (#99) from claude/issue-61 into master 2026-06-16 20:56:46 +02:00
catbot
1f12f074b1 Merge remote-tracking branch 'origin/master' into claude/issue-61
# Conflicts:
#	interfaces/Crafter.Graphics-Device.cppm
#	interfaces/Crafter.Graphics-VulkanBuffer.cppm
#	project.cpp
2026-06-16 18:33:08 +00:00
e86facc2af Merge pull request 'perf(image): batch final mip-chain layout transition (#70)' (#100) from claude/issue-70 into master 2026-06-16 20:31:16 +02:00
catbot
518509aa0e perf(image): batch final mip-chain layout transition (#70)
The mip-generation upload path issued N+1 layout-transition barriers per
chain. The final blit's destination level is never read again, so its
dedicated TRANSFER_DST -> TRANSFER_SRC barrier was redundant: the level was
transitioned to SRC only to keep the final SRC -> consumer transition uniform.

Skip that last per-level barrier and fold the final level into the closing
transition, which now batches two VkImageMemoryBarrier entries
([0, mipLevels-1) from TRANSFER_SRC, the last level from TRANSFER_DST) into a
single vkCmdPipelineBarrier. One fewer barrier call per chain; the N-1
interleaved per-level barriers stay one-at-a-time since each is read by the
next blit. Applied to both the host-upload and GPU-decompress Update paths.

Adds the MipChainBarrierBatch regression test driving the pure barrier
builder (no GPU device needed).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:30:44 +00:00
catbot
6d7ad87e38 perf(ui): flush only the written descriptor range, not the whole heap (#61)
WriteBufferDescriptor / WriteSampledImageDescriptor / RegisterSampler /
WriteSwapchainDescriptors each FlushDevice()'d the entire multi-KB per-frame
descriptor heap after writing one few-byte descriptor. Add a ranged
VulkanBuffer::FlushDevice(offset, bytes) that flushes only the touched bytes,
rounding the range outward to nonCoherentAtomSize as vkFlushMappedMemoryRanges
requires (the WHOLE_SIZE path sidestepped that). The coherent-memory gate from
issue #60 is preserved — coherent memory still skips the flush entirely.

The descriptor writers now self-flush their written range, so the redundant
whole-heap flushes in UIRenderer::Initialize and the resize callback are gone.

Rounding lives in AlignMappedFlushRange (pure math) and is unit-tested without
a device in the new VulkanBufferRangedFlush test; the change was also verified
end-to-end by running HelloUI on a real GPU with validation layers (font-atlas
glyphs, sampler and storage-image descriptors all flush correctly, no VUIDs).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:29:24 +00:00
00b84dd301 Merge pull request 'perf(rt): high-water-mark growth for TLAS host-input buffers (#64)' (#98) from claude/issue-64 into master 2026-06-16 20:24:48 +02:00
catbot
8e6ba49743 perf(rt): high-water-mark growth for TLAS host-input buffers (#64)
BuildTLAS reallocated the host-visible instanceBuffer and metadataBuffer
on every topology change. They only ever need to hold at least
primitiveCount entries (the AS build reads exactly primitiveCount via
tlasRangeInfo, and the copy loop writes [0, primitiveCount)), so a
shrink — or any growth that still fits the previous capacity — can reuse
the existing allocation. Gate the two Resize calls on a high-water-mark
check, removing two of the four reallocations on a count change. The AS
storage + scratch rebuild below is unchanged: it is tied to the AS
itself and dominates this path regardless.

Adds tests/TLASHighWaterMark driving the real hardware AS-build path: it
asserts a shrink (and within-capacity growth) reuses the buffers while a
growth past the high-water reallocates, that builtInstanceCount still
tracks the live count, and that feeding an oversized instance buffer to
the build produces zero Vulkan validation errors.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:24:09 +00:00
bd8076769a Merge pull request 'perf(pipeline): shared, disk-persisted VkPipelineCache (#69)' (#97) from claude/issue-69 into master 2026-06-16 20:24:03 +02:00
catbot
a728482731 test(pipeline): cover pipeline-cache header validation (#69)
PipelineCacheValidation drives Device::PipelineCacheDataCompatible
directly (no GPU): synthetic device identities + 32-byte cache headers
assert that a matching header is accepted while foreign vendor/device,
a bumped UUID (driver update), unknown version, undersized headerSize,
and short/empty blobs are all rejected. Mirrors the GPU-free style of
MemoryTypeFallback / UploadStrategy. Also gitignore the serialized
pipeline_cache.bin a real-device test run drops in the working dir.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:23:25 +00:00
catbot
b6d0ec5cae perf(buffer): capacity-reuse in VulkanBuffer::Resize (#63)
Resize previously always destroyed + reallocated. It now reuses the
existing allocation in place when the new request still fits the created
capacity and the immutable-at-create properties match: usage flags are
fixed at create, and the chosen memory type must still satisfy the
required property flags. preferredPropertyFlags is a best-effort perf
hint and is intentionally not part of the guard. On reuse the buffer
handle, device address and mapped pointer are preserved; only `size`
shrinks to the new logical extent.

Tracks capacity and the created usage flags on VulkanBufferBase, set at
Create and carried through the move constructor.

Adds VulkanBufferResizeReuse, a device-free regression test that drives
the reuse guard directly and asserts the in-place path issues no Vulkan
call.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 18:22:09 +00:00
catbot
cfbe181d9e feat(device): runtime upload-strategy helper PreferDirectDeviceWrite (#89)
Add Device::PreferDirectDeviceWrite(size) so the memory-placement cluster
(#65/#72/#73/#75) no longer re-derives where a CPU-written, GPU-read buffer
should live. It picks the strategy at runtime from Device::memoryProperties:

  - No DEVICE_LOCAL|HOST_VISIBLE type (no resizable BAR) -> false (must stage).
  - ReBAR/UMA (BAR heap >= 90% of largest DEVICE_LOCAL heap) -> true: map and
    write directly; a staging copy would be pure overhead.
  - Small BAR window (BAR heap << VRAM) -> true only for buffers within a
    per-window budget (1/8 of the window); large buffers stage so they don't
    exhaust the window (#58).

Complements GetMemoryType (#59): #59 picks the memory *type*, this picks the
*strategy*. Upload-only — readback (#74) wants HOST_CACHED and stays separate.
A VK_EXT_memory_budget-driven remaining-space check is noted as a follow-up.

Tested by tests/UploadStrategy (pure CPU over synthetic ReBAR / UMA /
small-window / no-BAR layouts, no GPU device needed), mirroring
MemoryTypeFallback.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:23:27 +00:00
catbot
c670bc70b7 Merge remote-tracking branch 'origin/master' into claude/issue-47
# Conflicts:
#	project.cpp
2026-06-16 17:10:39 +00:00
catbot
e8f1c7cff9 Merge remote-tracking branch 'origin/master' into claude/issue-47
# Conflicts:
#	interfaces/Crafter.Graphics-UI.cppm
#	project.cpp
2026-06-16 17:09:06 +00:00
catbot
bdbe3bf568 Merge remote-tracking branch 'origin/master' into claude/issue-60
# Conflicts:
#	project.cpp
2026-06-16 17:07:32 +00:00
bda21979fd perf(vulkan-buffer): skip flush/invalidate on coherent memory (#60)
FlushDevice/FlushHost called vkFlushMappedMemoryRanges /
vkInvalidateMappedMemoryRanges unconditionally. Record the chosen
memory type's propertyFlags at Create time (via the index GetMemoryType
returns, not the requested flags) and early-return when HOST_COHERENT.

The cmd overload still always emits its pipeline barrier — only the
redundant host-side flush/invalidate is gated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:04:26 +00:00
catbot
574242dd30 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
catbot
ce1eaf6366 Merge remote-tracking branch 'origin/master' into claude/issue-57
# Conflicts:
#	project.cpp
2026-06-16 17:02:48 +00:00
catbot
c169986835 perf(font): cache per-codepoint advances in font units (#57)
Font::GetLineWidth called stbtt_GetCodepointHMetrics for every glyph on
every invocation. InputField_HitTestCursor rescans the whole field once
per character, making caret hit-testing O(n²) in HMetrics lookups.

Memoise advances per Font via Font::AdvanceUnits: ASCII in a flat array,
the rest in a map. Advances are cached in unscaled font *units* and
rescaled per call — Glyph::advance in the FontAtlas is baked at kBaseSize
and is wrong at any other size, so it cannot be reused. The per-glyph
(int) truncation in GetLineWidth is preserved exactly.

Adds the FontAdvanceCache CPU-only regression test covering size
independence of the cached unit advance, the per-glyph-truncated rescale
pipeline, cache-hit determinism, width scaling with size, and the
non-ASCII map path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 17:00:48 +00:00
catbot
f2f5a770c0 Merge remote-tracking branch 'origin/master' into claude/issue-50
# Conflicts:
#	project.cpp
2026-06-16 16:59:05 +00:00
catbot
65c19ea84a perf(ui): gate per-pixel clip compares behind a flags bit (#50)
uiResolveScreenPixel ran four float compares against hdr.clipRectPx for
every pixel of every UI dispatch, even though the clip rect is the
full-surface default {0,0,1e9,1e9} in the overwhelmingly common case.

Reserve the high bit of the header flags word (UI_FLAG_CLIP) and have
FillHeader set it only when the clip rect is narrower than the surface
(extracted into the unit-testable UIRenderer::ClipFlags). The shader
gates the compares on the bit; the branch is push-constant-uniform so it
never diverges. When the rect covers the surface the skipped compares
could never reject an in-surface pixel, so output is pixel-identical.

Mirrored on the WebGPU/WGSL path (dom-webgpu.js) for parity. Adds the
UIClipFlag CPU test covering the gate decision.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:57:07 +00:00
catbot
d712218f17 perf(input-field): O(n) cursor hit test via Font::NearestCursorByte (#56)
InputField_HitTestCursor mapped a click x to a cursor byte offset by
calling Font::GetLineWidth on every prefix value[0..i] for i=1..size.
Each call re-walks from byte 0, so the hit test was O(n^2) glyph-metric
lookups (each an uncached stbtt cmap binary search). It also iterated
raw byte boundaries, so on multibyte UTF-8 input it could return an
offset splitting a codepoint — an invalid position for the byte-based
cursorPos.

Add Font::NearestCursorByte: one left-to-right cumulative-advance pass
(O(n) metrics, scale computed once) over codepoint boundaries, returning
the byte offset of the boundary nearest the target. Cumulative advance is
monotonic, so the scan stops past the closest boundary. The hit test now
delegates to it.

Adds tests/InputFieldHitTest, a pure-CPU regression test (no Vulkan
device/window): a px sweep across ASCII and multibyte strings compared
against an independent brute-force nearest-boundary oracle, plus the
left/right/empty/end-of-text edge cases and a codepoint-boundary invariant.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:55:03 +00:00
catbot
294c1378b5 fix(device): add preferred mask + required-only fallback to GetMemoryType (#59)
GetMemoryType was a bare first-superset match that threw whenever no
memory type satisfied the requested property flags. Callers combining a
mandatory flag with a perf-only one (HOST_VISIBLE | DEVICE_LOCAL for the
descriptor heaps) would then fail allocation on any device without a
host-visible device-local heap (no resizable BAR).

It now takes a `preferred` mask distinct from `required`: a type
satisfying both is chosen first, falling back to a required-only match
when the preference is unavailable, and throwing only when even
`required` cannot be met. VulkanBuffer::Create/Resize gain an optional
trailing `preferredPropertyFlags`, and the descriptor heaps now treat
DEVICE_LOCAL as a preference on top of mandatory HOST_VISIBLE.

This does not touch any flush/barrier behaviour — coherency is not
assumed anywhere here.

Adds tests/MemoryTypeFallback, a pure-CPU test that installs synthetic
memory layouts and exercises selection, preference, fallback, and the
unsatisfiable-required throw.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-16 16:03:50 +00:00