diff --git a/doc/performance/renderer-benchmark.md b/doc/performance/renderer-benchmark.md new file mode 100644 index 00000000000..cc011e1e9a8 --- /dev/null +++ b/doc/performance/renderer-benchmark.md @@ -0,0 +1,296 @@ +# Renderer benchmark protocol + +This benchmark classifies frame-time regressions before renderer fixes or a Vulkan port are chosen. It records low-overhead CPU phases and resource counters during normal rendering. GPU pass timings come from a separate diagnostic capture so the steady benchmark never waits for an OpenGL timer query. + +## Contract + +- Use a ReleaseOS build without validation or per-shader profiling for performance runs. +- Use the checked-in scenario without editing it. Operator-supplied locations and credentials are deliberately outside manifests and results. +- Run 30 seconds of warm-up, 120 seconds of capture, and five repeats unless the selected scenario documents a different warm-up. +- Keep cold-cache and warm-cache results separate. +- Disable VSync, automatic FPS tuning, and intentional yield time for throughput runs. Measure normal present behavior in a separate pacing run. +- Treat a change as meaningful only when its p95 delta exceeds both 1 ms and three times the run-to-run p95 range for that scenario. +- Randomize A/B order where practical. Repeat a threshold-crossing result on a second GPU or driver of the same class before generalizing it. + +The six versioned manifests under `scripts/perf/scenarios/` cover steady warm cache, cold streaming, avatars, draw and alpha pressure, GPU-heavy passes, and UI/HUD composition. A location is supplied with `--slurl`; it is never copied into the output or printed in a launch command. + +### Display contract + +The display contract introduced in schema version 2 fixes the benchmark render surface at 1280×720 backing pixels and the effective viewer UI scale at 1.0. Every checked-in scenario declares `RenderHiDPI: true`, `RenderBenchmarkUIScale: 1.0`, `WindowWidth: 1280`, `WindowHeight: 720`, and `WindowMaximized: false`. A scenario must not declare `UIScaleFactor`; the viewer derives that configured factor from the backing scale detected after window creation. Making HiDPI explicit ensures a fresh isolated profile exercises Retina backing on supported hardware instead of inheriting a local default. + +On a 1× display, the configured UI factor is 1.0. On a 2× Retina display, it is 0.5. Both produce an effective display scale of 1.0 without changing the existing meaning of `context.width` and `context.height`, which remain backing pixels. The result also records explicit backing and logical dimensions, backing scale on both axes, the configured UI factor, and the final effective display scale on both axes. + +The runner rejects a result when any explicit backing dimension differs from the legacy backing dimension or manifest, when logical size multiplied by backing scale does not reconstruct the backing size, when the configured factor does not produce the requested scale, or when the final display scale differs from the request. These fields must also match across repeated results. Schema versions 1 and 2 are not accepted by the version 3 runner or reporter because they do not carry the complete scene-validity proof. + +`RenderBenchmarkUIScale` defaults to zero, is not persisted, and has an effect only in a build compiled with `LL_RENDER_BENCHMARK`. Ordinary builds and non-benchmark launches retain the normal platform UI-scale behavior. + +### Scene-validity contract + +Schema version 3 turns the controlled-workload rules into a fail-closed contract. Each manifest declares an asset mode, population mode, UI mode, settlement window, motion limits, population limits, new-object limit, and simulator-ping limit. Each run also supplies a privacy-safe workload slug and typed operator assertions for power source, low-power mode, thermal state, material scene events, intended UI, and intended camera. + +The viewer exports only the live facts needed to evaluate those rules. During the end of warm-up and throughout capture, the collector checks placement, teleport and progress state, camera and avatar motion, focus, blocking UI, texture and mesh work, self-avatar completion, visible-avatar and active-object counts, new objects, circuit health, and simulator ping. Absolute location and camera coordinates are not retained. A rounded, agent-relative camera view is reduced to a hash before the raw view is discarded. + +The checked-in steady policy uses existing viewer semantics where they fit: the requested region with the existing 2 m X/Y placement tolerance, the scene-loading monitor's accumulated 0.1 m camera-translation and 0.05 rad camera-rotation limits, no population-count drift, and the mesh subsystem's 15-second no-progress horizon. Applying the 15-second window to the composite texture and mesh gate, requiring zero avatar travel and new objects, and rejecting any capture-wide ping above 600 ms are conservative benchmark policies. They are not universal definitions of viewer correctness. A strict gate may reject a usable-looking scene; it may not admit an ambiguous timing sample. + +Operator values are assertions, not platform telemetry. A valid run requires a known, repeatable power source, low-power mode off, a non-throttled thermal state, no material scene events, and approved UI and camera state. Power source and all operator fields must match across comparable repeats. AC power is not universally required. + +## Running + +Configure the viewer with `-DLL_RENDER_BENCHMARK:BOOL=ON`. The option defaults to `OFF`; ordinary builds therefore compile out the added phase timers, resource-counter updates, and benchmark LLLeap payload. The collector is an LLLeap child process. Do not use `--noninteractive`: that viewer mode intentionally skips world rendering. + +Create a credential file outside the repository. On POSIX it must have mode `0600`; the runner rejects group- or world-accessible files. On Windows, restrict its ACL to the benchmark operator. The file must contain exactly one non-comment line in one of these forms: + +```text +username password +first last password +``` + +A one-word username is sent with the legacy last name `Resident`. The runner never prints the account name, credential path, password, or SLURL. A dry run does not open the credential file. + +```bash +python3 scripts/perf/render_benchmark.py run \ + --viewer /path/to/secondlife-bin \ + --viewer-cwd /path/to/runtime/tree \ + --manifest scripts/perf/scenarios/steady-warm-v1.json \ + --credential-file /secure/path/demo-account.txt \ + --slurl secondlife://operator-supplied-location \ + --hardware-label linux-mesa-current \ + --workload-id controlled-steady-scene \ + --power-source ac \ + --low-power-mode off \ + --thermal-state nominal \ + --scene-events none \ + --ui-state approved \ + --camera-state approved \ + --expect-gpu-substring 'expected renderer text' \ + --backend native-gl \ + --output-dir /path/to/results +``` + +Use `--backend zink` for the matched Linux GL-on-Vulkan run. The runner sets `MESA_LOADER_DRIVER_OVERRIDE=zink`; the collector independently rejects the result unless the OpenGL renderer string contains Zink. Use the same commit, manifest, window size, scene, and GPU for both backends. + +For a no-secret launch check: + +```bash +python3 scripts/perf/render_benchmark.py run \ + --viewer /path/to/secondlife-bin \ + --manifest scripts/perf/scenarios/steady-warm-v1.json \ + --credential-file /path/that/need/not/exist \ + --slurl secondlife://operator-supplied-location \ + --hardware-label dry-run \ + --workload-id controlled-steady-scene \ + --power-source ac \ + --low-power-mode off \ + --thermal-state nominal \ + --scene-events none \ + --ui-state approved \ + --camera-state approved \ + --output-dir /tmp/renderer-results \ + --repeats 1 \ + --dry-run +``` + +### macOS local build and smoke + +The macOS benchmark uses the native OpenGL backend. Configure a local ReleaseOS app with benchmark instrumentation and tests enabled, but signing and crash reporting disabled. The following route was verified with a disposable Python virtual environment and a Nix-provided CMake executable. Run CMake directly from its Nix output instead of entering a Nix shell so the build continues to use Xcode's compiler and SDK environment. + +```zsh +viewer_root=/path/to/viewer +build_variables=/path/to/secondlife-build-variables +build_venv=/private/tmp/renderer-mac-build/venv +cmake_root="$(nix build --no-link --print-out-paths nixpkgs#cmake)" + +python3 -m venv "$build_venv" +"$build_venv/bin/python" -m pip install autobuild llsd + +cd "$build_variables" +set -a +source ./convenience Release +set +a + +cd "$viewer_root" +env \ + PATH="$cmake_root/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + PYTHON="$build_venv" \ + "$build_venv/bin/autobuild" configure -c ReleaseOS -- \ + -DCMAKE_C_COMPILER:FILEPATH=/usr/bin/clang \ + -DCMAKE_CXX_COMPILER:FILEPATH=/usr/bin/clang++ \ + -DCMAKE_OSX_SYSROOT:PATH=/Applications/Xcode.app/Contents/Developer/Platforms/MacOSX.platform/Developer/SDKs/MacOSX.sdk \ + -DPython3_EXECUTABLE:FILEPATH="$build_venv/bin/python" \ + -DPYTHON_EXECUTABLE:FILEPATH="$build_venv/bin/python" \ + -DLL_RENDER_BENCHMARK:BOOL=ON \ + -DLL_TESTS:BOOL=ON \ + -DPACKAGE:BOOL=ON \ + -DRELEASE_CRASH_REPORTING:BOOL=OFF \ + -DNON_RELEASE_CRASH_REPORTING:BOOL=OFF \ + -DENABLE_SIGNING:BOOL=OFF + +env \ + PATH="$cmake_root/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin" \ + "$build_venv/bin/autobuild" build -c ReleaseOS --no-configure + +"$cmake_root/bin/ctest" \ + --test-dir "$viewer_root/build-darwin-universal" \ + -C Release \ + -R INTEGRATION_TEST_RUNNER_lldir \ + --output-on-failure +``` + +The app executable is at `build-darwin-universal/newview/Release/Second Life Test.app/Contents/MacOS/Second Life Test`. A first local launch can exercise bundle resource lookup without credentials or login: + +```zsh +isolated_root="$(mktemp -d /private/tmp/secondlife-help.XXXXXX)" +SECONDLIFE_USER_DIR="$isolated_root/profile" \ + "$viewer_root/build-darwin-universal/newview/Release/Second Life Test.app/Contents/MacOS/Second Life Test" \ + --help +rm -r -- "$isolated_root" +``` + +Snapshot the normal profile and cache metadata before and after this check. The isolated profile should contain `data`, `logs`, `user_settings`, `browser_profile`, and `cache`, and the temporary root should be removed afterward. + +For an authenticated launch and export smoke, create a short manifest outside the repository from `steady-warm-v1.json`. Keep the display and validity policy unchanged, shorten capture duration, and use one measured repeat. Keep the 30-second warm-up so the declared 15-second settlement window remains meaningful. The warm scenario runs one unmeasured prime followed by the measured capture. Validate the temporary manifest before launching. Supply the credential file and controlled destination separately. + +```zsh +chmod 600 /secure/path/benchmark-account.txt + +python3 scripts/perf/render_benchmark.py validate \ + manifest /private/tmp/steady-warm-smoke.json + +python3 scripts/perf/render_benchmark.py run \ + --viewer "$viewer_root/build-darwin-universal/newview/Release/Second Life Test.app/Contents/MacOS/Second Life Test" \ + --manifest /private/tmp/steady-warm-smoke.json \ + --credential-file /secure/path/benchmark-account.txt \ + --slurl secondlife://operator-supplied-location \ + --hardware-label mac-apple-silicon-smoke \ + --workload-id controlled-steady-scene \ + --power-source ac \ + --low-power-mode off \ + --thermal-state nominal \ + --scene-events none \ + --ui-state approved \ + --camera-state approved \ + --expect-gpu-substring Apple \ + --backend native-gl \ + --output-dir /private/tmp/renderer-smoke + +python3 scripts/perf/render_benchmark.py validate \ + result /private/tmp/renderer-smoke/RESULT.json +``` + +The smoke validates app launch, native OpenGL selection, version 3 export, geometry, and scene-gate behavior only. It is not performance evidence and its timing fields must be discarded. A failed scene gate is useful smoke evidence but is not a benchmark result. + +### Asset and appearance readiness prime + +Use the prime-only mode to investigate a warm isolated profile before collecting a baseline. It runs the unchanged warm scenario twice against one disposable cache, emits no measured result, and reduces each launch to privacy-safe cache, asset, and appearance facts. The second launch exists to prove that the first launch's cache survives and remains writable. Other scene-gate failures, including placement and focus, remain explicit but do not prevent the asset and avatar readiness check from succeeding. + +```zsh +readiness_root="$(mktemp -d /private/tmp/renderer-readiness.XXXXXX)" + +python3 scripts/perf/render_benchmark.py run \ + --viewer "$viewer_root/build-darwin-universal/newview/Release/Second Life Test.app/Contents/MacOS/Second Life Test" \ + --manifest scripts/perf/scenarios/steady-warm-v1.json \ + --credential-file /secure/path/benchmark-account.txt \ + --slurl secondlife://operator-supplied-location \ + --hardware-label mac-apple-silicon-readiness \ + --workload-id controlled-steady-scene \ + --power-source ac \ + --low-power-mode off \ + --thermal-state nominal \ + --scene-events none \ + --ui-state approved \ + --camera-state approved \ + --expect-gpu-substring Apple \ + --backend native-gl \ + --output-dir "$readiness_root/results" \ + --repeats 1 \ + --warm-prime-attempts 2 \ + --prime-only \ + --readiness-output "$readiness_root/readiness.json" + +python3 -m json.tool "$readiness_root/readiness.json" +``` + +A readiness pass has `readiness_passed: true`, `cache_reuse_passed: true`, two attempts, zero valid measured repeats, and `retained_timing: false`. On the second attempt, the requested cache root and nested asset root must remain writable, the fixed disposable asset sentinel must be `ready` before and after launch, the fallback asset root must remain absent, and `first_cache_failure` must be `none`. Both target gates must be true. Report any remaining names in `failed_gates` separately. Asset readiness includes separate settlement and queue booleans; avatar readiness separates appearance completion from unintended movement. + +In prime-only mode, each guarded poll also requests a benchmark-only paired scene and appearance snapshot. Those two diagnostic facts come from the same main-thread response; the collector rejects a response if their avatar-ready booleans disagree. It retains the last snapshot paired with a failed `self_avatar_loaded` sample, or the final snapshot when every guarded sample passed. The viewer exposes only fixed booleans for self-avatar validity, COF presence and completeness, COF-change context, resolved required links, delivered required wearables, and final avatar readiness. The four fixed required parts are shape, skin, hair, and eyes. + +The appearance category has this precedence: + +- `avatar-unavailable` +- `cof-incomplete` +- `required-link-missing-or-unresolved` +- `wearable-delivery-pending-or-failed` +- `avatar-later-blocker` +- `ready` + +`required-link-missing-or-unresolved` is deliberately combined because a link whose target is absent from the local inventory cannot reveal its intended wearable type. `wearable-delivery-pending-or-failed` is also combined because the stable public appearance APIs do not distinguish an outstanding asset request from a terminal failure. `cof_change_in_progress` is context, not proof of either condition. The runner recomputes the category from the projected booleans and rejects contradictory facts. If appearance remains incomplete after cache readiness passes, use only these facts to choose the next investigation; do not change the account outfit without authorization. + +The readiness file contains aggregate counts, booleans, and allow-listed categories only. It contains no frames, timing summary, account, destination, inventory or asset identifiers, item names, raw log line, or filesystem path. Appearance facts are optional in schema-3 results and do not participate in a gate, policy hash, manifest hash, comparison field, or summary. Normal primes and measured runs never request the diagnostic operation. Report the safe readiness fields, then remove the readiness root, any temporary credential file, private logs, isolated state, and raw prime artifacts. + +The runner sets `SECONDLIFE_USER_DIR` and creates session settings, cache, logs, and account data inside a private per-invocation temporary directory. It precreates the user and cache roots once, never repairs them between warm launches, selects the same explicit path in both cache-location settings, and disables legacy cache migration for the isolated session. The viewer also skips migration when its normalized source and destination are identical while preserving migration between different locations. The runner does not read or alter the normal viewer profile, and the isolated data is removed after the sequence exits. Cold-cache repeats receive separate state and purge before startup. Warm-cache sequences first run one unmeasured full-duration prime, then reuse that isolated profile and cache for all measured repeats. The prime is validated but its artifact is discarded. Cache probes and the sentinel are active only in prime-only diagnosis; measured benchmark launches never execute them. First-install UI, notifications, audio, and voice are disabled for benchmark sessions so they cannot cover the workload or crash a headless test host. + +After the viewer reaches its started state, the LLLeap collector reapplies the requested scenario settings. This ordering prevents hardware feature-table initialization from replacing explicit benchmark controls. Every prime and measured result is rejected if an effective setting differs from the manifest. A non-maximized result is also rejected unless its actual backing-pixel width and height exactly match the requested resolution. On macOS, the XIB-owned app window converts requested backing pixels to Cocoa content dimensions so Retina scaling does not change the rendered resolution. The benchmark-only display target then normalizes the viewer UI scale from the detected backing scale. + +## Result and comparison rules + +Raw artifacts use `renderer-benchmark-result.schema.json`, schema version 3. Each valid result contains: + +- scenario, repeat, cache mode, requested settings hash, and manifest hash; +- viewer version, source commit, tracked-diff hash/dirty state, build type, OS, CPU and logical-core count; +- operator hardware label, GPU, driver, reported VRAM, requested and detected backend; +- OpenGL version/profile, limits, extension set and hash, shader level, feature flags, actual settings and hash, backing resolution, logical content size, backing scale, configured UI factor, and effective display scale; +- the workload slug, policy and policy hash, typed operator assertions, privacy-safe scene observations, every derived gate, and a relative-view hash; +- per-frame median inputs plus p95, p99, worst frame and 1 percent low FPS; +- geometry creation, partition, geometry update, culling, shadows/impostors, texture work, state sort, rebuild, GL submission, deferred lighting, UI/HUD, swap, idle, and unclassified CPU time; +- draw count, batch size, triangles, shader program changes, texture uploads, texture readbacks, explicit texture synchronization, shader compilation, and tracked texture memory. + +`rebuild_ms` is nested inside `state_sort_ms` and is diagnostic detail; do not add both to a total. The other named phases are non-overlapping at their instrumentation sites. `unclassified_ms` is frame time minus the non-nested named phases, clamped to zero. Texture readbacks currently count `LLImageGL::readBackRaw`; framebuffer screenshot and picker readbacks need an external trace. Texture bytes are source upload estimates, not bus-transaction measurements. Shader compile time is CPU time inside `glCompileShader` and its status query; later driver pipeline work can still appear in submission or an external trace. + +Generate a report with: + +```bash +python3 scripts/perf/render_benchmark.py report /path/to/results/*.json \ + --format markdown \ + --output summary.md +``` + +The reporter rejects different scenarios, manifest hashes, requested or actual settings, feature sets, source commits, build types, resolutions, instrumentation modes, workload slugs, validity policies, operator assertions, relative camera views, or stable population counts. Extension sets must match between repeats of one backend; native OpenGL and Zink extension differences are expected and their hashes remain visible in the cross-backend report. `--allow-mismatch` is an audit escape hatch; the mismatches are printed into the report. + +## Invalid runs + +Discard a run and preserve its reason when any of the following applies: + +- login or startup did not finish; +- too few frames were captured; +- requested and detected backends differ; +- the selected GPU does not match `--expect-gpu-substring`; +- placement does not match the requested start location; +- asset loading is incomplete for a settled run; +- scene population, camera, avatar, window focus, feature flags, or resolution changed; +- blocking UI, progress UI, or unapproved controlled UI is present; +- the circuit is unhealthy or simulator ping exceeds the scenario policy; +- a material scene event was observed; +- power source is unknown, low-power mode is enabled, or thermal state is unknown or throttled; +- visual comparison differs outside the project's accepted tolerance. + +The collector measures viewer facts and derives each gate. Power, thermal, scene-event, intended-UI, and intended-camera values remain typed operator assertions. The runner independently recalculates the gates, checks their policy and manifest hashes, and rejects tampering or drift. Never silently average invalid runs into a summary. + +## GPU and system diagnostics + +Run one separate diagnostic capture for each important steady configuration using Tracy plus RenderDoc, Nsight Graphics, Radeon GPU Profiler, or the platform's equivalent. Record GPU pass timestamps, queue idle, driver waits, readbacks, pipeline/shader creation, per-thread CPU traces, and per-core utilization. Keep large captures as CI or investigation artifacts, not in Git. + +Do not enable the viewer's per-shader Frame Profile for steady runs: it reads query results and can change the workload. Preserve the feature manager's existing AMD RDNA 3.5 query safeguards. A Zink result is Linux evidence only; it is not proof that Windows or macOS will behave the same way. + +## Privacy + +The C++ event API selects safe fields from viewer information rather than exporting the full About/profile context. Absolute placement and an agent-relative camera view exist only in the private collector process long enough to derive a placement boolean and relative-view hash. The runner removes account, credential, machine ID, serial, hostname, position, camera origin/orientation, parcel, region, location, and raw-view keys recursively before writing or reporting an artifact. Checked-in fixtures use synthetic hardware strings. Review `find_private_paths()` results before publishing a new fixture. + +## Validation + +```bash +python3 -m unittest discover -s scripts/perf/tests -v +python3 scripts/perf/render_benchmark.py validate manifest scripts/perf/scenarios/steady-warm-v1.json +python3 scripts/perf/render_benchmark.py validate result scripts/perf/fixtures/renderer-result-v3.json +``` + +The tests cover all manifests, the 1× and 2× scale contract, scene policy, wrong placement, focus loss, unsettled assets, population drift, unexpected UI, gate tampering, cache survival, launch-bounded log categories, scale and resolution mismatches, comparison drift, percentile calculations, resource deltas, recursive privacy filtering, the checked-in fixture, and the no-secret dry-run path. Focused C++ integration tests cover scale derivation, the disabled normal-launch path, and the cache self-migration guard. diff --git a/doc/performance/renderer-stage-1-decision.md b/doc/performance/renderer-stage-1-decision.md new file mode 100644 index 00000000000..492915f43d1 --- /dev/null +++ b/doc/performance/renderer-stage-1-decision.md @@ -0,0 +1,99 @@ +# Stage 1 renderer benchmark decision + +## Decision + +Do not begin a Vulkan implementation or select an OpenGL optimization from the measurements collected in Stage 1. The benchmark suite is ready, but the available live-world workload was not controlled well enough to diagnose the reported newer-hardware regression. + +Stage 2 will establish a deterministic workload on affected newer hardware, verify the context the driver actually creates, and capture external CPU/GPU traces. It will not add Vulkan. A current-OpenGL fix or a renderer-interface spike becomes eligible only after that evidence identifies the limiting path. + +## What Stage 1 delivered + +- Compile-gated CPU phase timers and renderer resource counters. Ordinary builds define `LL_RENDER_BENCHMARK=0` and compile the instrumentation out. +- A benchmark-safe LLLeap event that exports selected renderer, build, settings, and hardware context without exporting the normal profile or location context. +- Versioned manifest and result schemas, six workload manifests, a synthetic fixture, an isolated-profile runner, and a guarded reporter. +- Native OpenGL and Mesa Zink backend detection, including a check against the renderer string rather than trusting the requested backend. +- A 30-second warm-up, 120-second capture, five-repeat protocol. Warm-cache sequences use an unmeasured prime and one shared isolated cache; cold-cache repeats receive separate state. +- Comparison and privacy tests that reject incomplete results, unlike configurations, too few repeats, and identifying keys. + +The steady loop adds no GPU query reads. GPU pass timing remains an explicitly separate RenderDoc, Radeon GPU Profiler, Nsight Graphics, Tracy, or equivalent capture. + +## Reference host and coverage + +The suite was built and launched on Linux with an AMD Ryzen 7 5800X, an AMD Radeon RX 480, Mesa 26.2.1, and the RADV Vulkan driver. Native OpenGL and Zink both selected the physical RX 480. This is an older reference machine, not the reported newer-hardware class. + +The following required matrix cells were not available and no conclusion is drawn for them: + +- affected newer GPU and a matched older control; +- Windows on current NVIDIA, AMD, and Intel drivers; +- Linux on current Intel and NVIDIA drivers; +- macOS on Apple Silicon and a still-supported Intel baseline; +- a second machine or driver from any class showing a threshold-crossing result. + +The local build disabled media plugins because their development dependencies were not present. A GCC 15 false positive in an untouched source file required a generated-build-only warning suppression. Neither deviation is part of the commit. + +## Diagnostic matrix + +Five native OpenGL and five Zink captures completed end to end. They are invalid as steady warm-cache performance evidence: the viewer did not honor the requested destination, first-use UI opened over the scene, assets were still arriving, and each initial repeat had a fresh profile and cache. The runner now performs an unmeasured prime and reuses warm state, but the live location and first-use UI still cannot be gated automatically. Raw captures and screenshots are intentionally not checked in because they include live-world details. + +The invalid captures remain useful as a harness and noise diagnostic: + +| Backend | Median run p50 | Median run p95 | Median run p99 | Run-p95 range | +| --- | ---: | ---: | ---: | ---: | +| Native OpenGL | 5.155 ms | 8.634 ms | 13.266 ms | 4.21 ms | +| Zink | 5.399 ms | 7.364 ms | 11.071 ms | 2.70 ms | + +Zink's median p95 was 1.27 ms lower. The predeclared threshold for this matrix is the larger of 1 ms and three times the largest run-p95 range: 12.63 ms. The observed delta is below that threshold and cannot support a backend claim even if the scene had been valid. + +The largest median-of-run p95 CPU phases were: + +| Phase | Native OpenGL | Zink | +| --- | ---: | ---: | +| Unclassified | 3.180 ms | 2.738 ms | +| Swap | 0.319 ms | 1.602 ms | +| Idle | 1.355 ms | 1.256 ms | +| GL submission | 1.275 ms | 0.827 ms | +| State sort | 0.870 ms | 0.541 ms | +| Texture work | 0.553 ms | 0.445 ms | + +Texture uploads varied too widely to treat the runs as the same workload. Native upload bytes ranged from roughly 1.0 MB to 521.7 MB; Zink ranged from roughly 1.0 MB to 360.7 MB. No explicit texture readbacks, synchronization events, or shader compilations occurred during the capture windows. External GPU pass timing and per-core traces were not collected because the workload had already failed its validity checks. + +The initial artifacts labeled the profile from the requested core-profile setting. The advertised OpenGL version was actually a Mesa 4.6 compatibility profile. The exporter now derives `opengl_profile` from the advertised version and records the requested setting separately; a short post-fix launch verified that distinction. Results collected before that fix are not comparison inputs. + +## What the evidence supports + +- The build option, viewer export, isolated runner, schemas, backend verification, reporter, and native/Zink launch path work end to end on the Linux reference host. +- Live-world asset and UI variability is larger than the backend delta seen here. +- Zink is viable as a Linux diagnostic backend on this host, but this run says nothing about Windows, macOS, or affected newer hardware. +- The actual versus requested OpenGL profile must be treated as separate data. Context negotiation is now a specific investigation target. + +## What the evidence does not support + +- that OpenGL compatibility behavior causes the reported regression; +- that Zink or native Vulkan would improve it; +- that scene preparation, GL submission, present, or a GPU pass is the primary bottleneck; +- a visual-equivalence claim between native OpenGL and Zink; +- any cross-platform or newer-hardware performance conclusion. + +No current-OpenGL fix is justified yet. Candidate fixes must come from a trace and may include eliminating an identified driver wait, reducing state or draw submission, correcting context selection, or reducing resource churn. They are hypotheses, not Stage 1 findings. + +## Stage 2 selection + +Stage 2 is **capture the affected-hardware regression**. Its commit will add the smallest deterministic workload and validity gates needed to obtain five comparable runs on an affected current system and an older control. It will also capture actual context negotiation, per-core CPU behavior, and one external GPU trace per important configuration. + +The decision gate at the end of Stage 2 is: + +- fix the current OpenGL path first when the controlled trace identifies a bounded scene-preparation, submission, synchronization, present, or GPU-pass bottleneck; +- proceed to an explicit dual-backend contract only when the measured problem is inseparable from the current API boundary or when a separately funded platform-longevity requirement justifies it; +- do not use GL-on-Vulkan as a production compatibility layer unless matched visual, feature, stability, and frame-time evidence shows that it is a supportable deployment path. + +## Verification + +- Benchmark-enabled Linux Release build linked and launched. +- Benchmark-disabled touched objects compiled, confirming the default-off path. +- Six manifests and the synthetic result validate against their JSON schemas. +- Eighteen runner and reporter unit tests pass. +- Native OpenGL and Zink selected the expected physical GPU and completed capture/export. +- Dry-run coverage verifies that credentials are neither opened nor printed. +- A native visual launch rendered the world without gross corruption; deterministic screenshot parity remains a Stage 2 gate. + +Stage 1 changes no production service, live database, normal viewer profile, shader, renderer output, or Vulkan dependency. diff --git a/doc/performance/renderer-stage-10-decision.md b/doc/performance/renderer-stage-10-decision.md new file mode 100644 index 00000000000..87bb202be31 --- /dev/null +++ b/doc/performance/renderer-stage-10-decision.md @@ -0,0 +1,146 @@ +# Stage 10 OpenGL tonemap replay decision + +## Decision + +Accept the tonemap packet and narrow OpenGL executor as master Stage 1B. The +same immutable packet now validates independently and drives one real viewer +pass through the existing OpenGL objects. The legacy implementation remains +the production default, and a rejected packet falls back before the destination +is bound or modified. + +This does not complete master Stage 1. There is no Vulkan executor, dual-API +comparison, material replay, streaming-upload replay, or performance result. +The live-world baseline remains paused after zero valid measured repeats. + +## Frozen trace + +Tracing corrected and completed the earlier representative fixture: + +- the four-word parameter block is `{ exposure, tonemap mix, tonemap type, + gamma }`; gamma was previously a hidden environment-fed shader input; +- all six compiled variants are represented by stable bits for `NO_POST`, + `GAMMA_CORRECT`, and `LEGACY_GAMMA`, including the currently unreachable + legacy-gamma/non-no-post combination; +- the destination may be RGBA8 or RGBA16F and its extent is dynamic; +- the scene and exposure render-target textures retain mirrored addressing, + with point and linear filtering respectively; +- disabled depth retains the legacy `LEQUAL` comparison; and +- the real screen triangle remains three `Float3` positions in a 48-byte + buffer with a 16-byte stride. + +The pure builder owns a copy of the parameter bytes and rejects invalid +variants, formats, extents, handles, exposure ranges, and non-finite values. +The decoder accepts only the canonical packet shape rather than treating any +otherwise valid frame as a tonemap request. + +## Boundary and fallback + +`lltonemapcontract` remains part of the independently linked, GL-free contract +target. It contains no viewer settings, sky state, render-target pointers, +shader objects, GL types, or API calls. + +The OpenGL registry borrows existing `LLRenderTarget`, `LLVertexBuffer`, and +`LLGLSLShader` objects for one synchronous replay. Resolution requires the +exact typed handle index and generation, and pipeline resolution also requires +the exact program name and variant. These handles are lexical replay handles; +they are not yet persistent ownership records across reallocations or shader +reloads. + +The executor decodes the complete packet and resolves every object, format, +extent, sampler, shader feature, permutation, texture channel, and required +uniform before binding the destination. It then applies the packet's color, +blend, cull, depth, viewport, scissor, sampler, parameter, geometry, and draw +state through the existing wrappers. The developer setting +`RenderUseTonemapContract` is non-persistent and defaults to false. Failure in +the builder, registry, or executor takes the independent legacy path once. + +## Fixed-input parity + +The account-free `--tonemapparity` startup mode runs after GL and shader +initialization and exits before login. It creates an 8 by 8 HDR scene, a 1 by 1 +exposure image, and separate legacy and contract destinations. Before each +path, it poisons shader uniforms, texture bindings, vertex-buffer binding, and +relevant GL state so equality cannot come from cached state inherited from the +other path. Before contract execution it also poisons the actual sampler +objects and blend, cull, scissor, and color-mask state. + +The reference side calls the same direct submission helper used by the +production legacy path. Before every case, the two destinations receive +different case-specific sentinels, and both draws are bracketed by explicit GL +error checks. A missing draw therefore cannot pass by reusing an old output or +by making both paths fail in the same way. + +The matrix covers both output formats, all six compiled variants, and both +supported tonemap types: 24 cases. Every RGBA component is compared after +readback with a declared tolerance of zero. A separate stale-generation case +requires preflight rejection and proves that an RGBA8 destination sentinel is +unchanged. + +On the Apple OpenGL 4.1 context, all 24 cases matched exactly: + +```text +TONEMAP_CONTRACT_PARITY result=pass cases=24 tolerance=0 max_abs_error=0 mismatches=0 execution_failures=0 rejection_failures=0 +``` + +This is correctness evidence for one OpenGL pass. It is not a performance +measurement and says nothing about Vulkan speed. + +## Measured surface + +| Surface | Size | +| --- | ---: | +| Pure tonemap contract header and implementation | 386 lines | +| OpenGL registry and executor | 395 lines | +| Focused registry tests | 80 lines | +| Existing contract fixture changes | 87 additions, 41 removals | +| Render-target and build wiring | 16 additions, 1 removal | +| Viewer integration, parity harness, and Mac isolation | 536 additions, 45 removals | + +The 395-line API adapter is deliberately pass-specific. Material, upload, +presentation, window, and general-purpose command-encoder behavior did not +enter it. + +## Verification + +- Linux passed 18 contract cases and 2 registry cases. +- macOS passed the same 18 and 2 focused cases on the host architecture. +- The account-free native OpenGL parity mode passed all 24 exact comparisons + and the stale-generation non-mutation check. +- macOS rejected parity startup without `SECONDLIFE_USER_DIR` before Cocoa or + viewer construction. The XIB now starts the main window hidden; ordinary + launches order it forward after context creation, while parity keeps it + hidden and still creates the required GL context. +- The benchmark-enabled universal Release viewer built after the final changes + and contains both `x86_64` and `arm64` executable slices. +- All 57 Python benchmark tests passed. Draft 2020-12 validation accepted both + schemas, all six manifests, and the schema-3 fixture. +- GCC 15 accepted the pure builder and decoder as C++20 with `-Wall -Wextra + -Wpedantic -Werror`. +- Whitespace validation passed, and adversarial review found no remaining + correctness blocker in the executor or parity boundary. + +The parity launch used a disposable profile and no credentials. Its temporary +profile, cache, logs, build environment, and incidental startup output were +removed. No benchmark result or timing was retained. + +## Reanalysis + +The next dependency is no longer another OpenGL abstraction. The tonemap +packet has a reference output and a proven executor boundary, so master Stage +1 now needs the same packet consumed by an isolated Vulkan process. + +Repository inspection found no Vulkan headers, loader target, MoltenVK package, +SPIR-V compiler, shader manifest, or Vulkan implementation. Existing GLSL is +compiled and reflected at runtime through OpenGL-specific shader management. +The next commit therefore has to establish a portable Vulkan toolchain and an +offscreen executor together, while keeping platform surfaces and GL/Vulkan +interop out of scope. A dependency-only commit would not test the contract; a +windowed or whole-frame backend would jump ahead of the master plan. + +Stage 11 should end with the canonical tonemap packet replayed into offscreen +Vulkan images from the same fixed CPU fixture, validation enabled, deterministic +readback compared with the Stage 10 reference, and an explicit portability +result on Linux and MoltenVK-capable macOS. If shader translation or platform +availability prevents that bounded replay, the stage must commit only an +evidence-backed stop decision and remove unused scaffolding rather than widen +into presentation or production renderer work. diff --git a/doc/performance/renderer-stage-11-decision.md b/doc/performance/renderer-stage-11-decision.md new file mode 100644 index 00000000000..e36000538fa --- /dev/null +++ b/doc/performance/renderer-stage-11-decision.md @@ -0,0 +1,161 @@ +# Stage 11 offscreen Vulkan tonemap replay decision + +## Decision + +Accept the standalone Vulkan tonemap replay as the completion of the first +dual-API renderer slice. The same immutable packet and synthetic resources now +drive the existing OpenGL pass and a native Vulkan command buffer in separate +processes. Both backends produce finite output within the tolerance fixed +before execution, and Vulkan validation stays clean on Linux RADV and macOS +MoltenVK. + +The viewer still starts through OpenGL. Vulkan remains an opt-in test-only +dependency and owns no window, surface, swapchain, viewer frame, or production +resource. This result proves a narrow correctness seam. It does not establish a +performance benefit and does not complete master Stage 1: the indexed material +draw and streaming texture upload still lack real dual-API execution. + +## Shared diagnostic boundary + +The Stage 10 fixture is now API-free and independently linked. It owns the +asymmetric 8 by 8 half-float scene, 1 by 1 exposure image, screen triangle, +six shader variants, two tonemap types, two destination formats, and all 24 +canonical packets. Both executors consume those packets rather than rebuilding +equivalent private descriptions. + +The versioned artifact records case identity, extent, format, bottom-left row +order, and finite normalized components. Publication creates a unique sibling +temporary file exclusively, writes and closes it completely, and installs it +with an atomic no-replace hard link. Existing destinations are rejected. The +OpenGL exporter canonicalizes readback to the actual RGBA8 or RGBA16F storage +codes before publication. + +## Shader and Vulkan boundary + +The Vulkan shaders are declaration wrappers around the existing tonemap +sources. They add explicit vertex, descriptor, fragment-output, and push- +constant interfaces without copying the sRGB or tonemap equations. The build +produces one vertex module and six fragment variants in the build tree and +validates every module with `spirv-val`. + +The pass-specific registry borrows one run's Vulkan buffers, image views, +samplers, pipelines, descriptor sets, render passes, and framebuffers. Before +beginning the command buffer or changing an image, the executor checks exact +handle generations, program and variant, formats, extents, required usage bits, +sampler behavior, descriptor bindings, vertex layout, push-constant size, and +the canonical packet shape. It then applies the declared image transitions, +translated bottom-left viewport and scissor, descriptors, parameters, vertex +buffer, and three-vertex draw, and leaves the destination shader-readable. + +Eight incompatible-resource mutations are rejected in preflight with no +submission. The native runner executes 24 valid packets and records exactly 24 +submissions. + +## Fixed-input comparison + +On Apple Silicon, the OpenGL exporter and native arm64 MoltenVK process matched +all 6,144 components exactly: + +```text +TONEMAP_COMPARE result=pass cases=24 components=6144 mismatches=0 max_abs_error=0 rgba8_tolerance=0.00392156886 rgba16f_tolerance=0.001953125 +``` + +Linux RADV output compared with the same Mac OpenGL reference without a failed +case or component. Its maximum absolute delta was `0.00392156839`, just under +the frozen `1/255` RGBA8 allowance. The RGBA16F allowance remained `2/1024`. +No output contained a non-finite component. + +These are correctness comparisons only. No frame, draw, build, startup, or +test timing was collected as migration evidence. + +## Code size + +| Code | Size | +| --- | ---: | +| Shared fixture, artifact, and comparison implementation | 960 lines | +| Vulkan registry and executor | 465 lines | +| Standalone Vulkan runner and artifact comparator | 1,841 lines | +| Vulkan shader wrappers | 48 lines | +| Focused diagnostic and registry tests | 406 lines | +| Opt-in CMake and option wiring | 240 additions | + +The 465-line API adapter is the migration-cost signal for this pass. Most of +the remaining code handles capability checks, resource creation, validation, +readback, artifacts, and portability for a process that +does not borrow production state. + +## Verification + +- All seven generated SPIR-V modules passed Vulkan 1.1 validation, with no + partially generated module left behind. +- Linux passed the 2-case Vulkan registry suite and the native RADV runner: + 24 valid cases, 8 preflight rejections, 24 submissions, and zero validation + messages. Portability enumeration was available; portability subset was not + advertised. +- macOS passed the same 2-case registry suite and the native arm64 MoltenVK + runner with 24 valid cases, 8 preflight rejections, 24 submissions, zero + validation messages, portability enumeration, and portability subset. +- The macOS Vulkan runner, comparator, and build-tree loader contain both + `x86_64` and `arm64` slices. Their ad hoc signatures verify, and the runner + resolves the loader through `@rpath`. +- The account-free Mac OpenGL exporter retained its 24 exact legacy-versus- + contract comparisons and wrote the reference only after passing them. +- The default universal Mac viewer built and packaged with the option off. Its + executable contains both architectures and no Vulkan or MoltenVK linkage. +- The default Linux graph contains no Vulkan target or cached Vulkan discovery. + Its viewer build required only a build-local suppression for an existing GCC + 15 `-Warray-bounds` false positive outside the Stage 11 files. +- Linux and macOS each passed the 18 contract, 4 diagnostic, and 2 OpenGL + registry cases. Both also passed the 2 Vulkan registry cases in their opt-in + builds. +- All 57 Python benchmark tests passed. The checked-in benchmark manifest and + schema-3 fixture also validated. +- GCC accepted the diagnostic, executor, runner, and comparator as C++20 with + warnings treated as errors; the Vulkan runner additionally suppresses the + aggregate `missing-field-initializers` warning for canonical `{sType}` Vulkan + structure initialization. Whitespace validation passed, and an adversarial + review found no remaining material issue. + +## Dependency friction + +No Vulkan Autobuild package or production package dependency was added. The +opt-in build takes explicit Vulkan header, loader, validation-layer, +`glslangValidator`, and `spirv-val` paths supplied by a Nix shell. +Rebuilding that target outside the declared shell cannot rely on the compiler's +cached implicit include environment; rebuilding inside it succeeds. + +The universal Mac test target required combining separate arm64 and x86_64 +Nix loader slices, correcting the dylib install name, adding a build-tree +runtime path, and signing the result. The available loaders target macOS 14 +while the viewer still declares macOS 11, so the opt-in link emits a deployment +warning. This test-only loader is neither installed nor packaged with the +viewer. A maintained dependency package would be required before Vulkan could +enter ordinary builds. + +The default Linux build also exposed X11's `None` and `Always` macros colliding +with contract enum members when the viewer's precompiled GL headers came first. +The contract now uses `Disabled` and `AlwaysPass` names. This avoids local +preprocessor exceptions and keeps the header safe in both API implementations. + +Windows remains unclaimed. Stage 11 established no presentation, interop, +runtime selection, recovery, or performance result. + +## Reanalysis + +The tonemap slice now satisfies the master Stage 1 requirement for one +full-screen pass through both APIs. The next unmet slice is the indexed +material draw, which is materially different: it needs an index buffer, seven +vertex streams, three sampled textures with mip and anisotropy behavior, a +160-byte parameter block, three G-buffer color attachments, depth load and +write, culling, and indexed ranges. + +The current material description is only a validation-test fixture with zeroed +bytes. Upstream's class 1 deferred material fragment source is also an explicit +constant-output debug stub, so it cannot serve as a reference. The class 3 +source still implements the candidate non-rigged normal-plus-specular path and +samples the inputs represented by the packet. Stage 12 must first prove that +the target context selects that implementation, then freeze one asymmetric +fixture and replay it through a narrow OpenGL executor with account-free +readback. If class 3 is not reachable, the stage must stop rather than accept +the stub. Vulkan material execution, streaming uploads, presentation, +packaging, runtime selection, and performance work remain later stages. diff --git a/doc/performance/renderer-stage-12-decision.md b/doc/performance/renderer-stage-12-decision.md new file mode 100644 index 00000000000..1c241caa634 --- /dev/null +++ b/doc/performance/renderer-stage-12-decision.md @@ -0,0 +1,191 @@ +# Stage 12 indexed material OpenGL replay decision + +## Decision + +Accept the account-free OpenGL material replay as the reference half of the +second master Stage 1 slice. One immutable backend-neutral packet now drives a +direct legacy submission and a narrow contract executor against the existing +class 3 non-rigged normal-plus-specular shader. Their three G-buffer +attachments and depth target match exactly on Apple Silicon. + +This is a correctness result, not a performance result. The production viewer +still renders through OpenGL. This stage adds no Vulkan material code, +streaming upload executor, presentation path, runtime backend selection, live +world input, account input, or timing. + +## Frozen material trace + +The target is `gDeferredMaterialProgram[12]`, reported by the viewer as +`Material Shader 12` at shader class 3. The executor rejects any other program, +variant, shader class, source pair, define set, feature set, attribute +interface, fragment-output mapping, sampler channel mapping, uniform mapping, +or reflected uniform-block layout. The selected program uses the existing +`deferred/materialV.glsl` and `deferred/materialF.glsl` sources with normal and +specular maps enabled. No material equation was copied and the class 1 debug +stub is not accepted. + +Reflection also freezes the shader's single `ReflectionProbes` uniform block: +binding 0, 49,248 bytes, and exactly 12 active members. This block is not a +material packet input for the selected fixture, but rejecting a different +layout prevents a silently changed shader interface from becoming the oracle. + +The frozen draw contains four vertices and six 16-bit indices. Its 304-byte +planar vertex allocation contains position, normal, three texture-coordinate +streams, color, and tangent with exact locations, component types, strides, +offsets, buffer bindings, and zero divisors. The index allocation is 12 bytes +and must contain `{0, 1, 2, 0, 2, 3}`. + +Three distinct RGBA8 textures contain asymmetric 4 by 4, 2 by 2, and 1 by 1 +mip levels. Sampling is linear with linear mip selection, repeat addressing, +and 8x anisotropy. The draw uses an 8 by 8 viewport and scissor, back-face +culling with counter-clockwise fronts, depth test and write with `LEQUAL`, fill +mode, and replacement blending. Dithering, multisampling, framebuffer sRGB, +stencil, logic operations, primitive restart, and unrelated raster features +are disabled for deterministic storage. + +The three color targets are RGBA8, RGBA8, and RGBA16 UNORM. The contract calls +the depth target depth24 UNORM. Apple's OpenGL implementation reports the +requested depth storage as `GL_DEPTH_COMPONENT32`; the executor permits only +that known substitution or native depth24 and canonicalizes readback to +24-bit codes. + +## Corrected parameter boundary + +The planned 160-byte parameter placeholder was incomplete. The reachable +shader consumes four transforms, specular color, clip plane, environment +intensity, emissive state, and mirror state. The frozen backend-neutral block +is therefore 272 bytes, or 68 32-bit words, with compile-time size and offset +checks. Both builder and decoder require the complete block and reject +non-finite or out-of-policy values. + +This is a correction to the Stage 12 estimate, not scope growth. Keeping the +160-byte placeholder would have hidden shader-visible mutable state outside +the packet and invalidated the migration seam. + +## Fixture, artifact, and ownership + +The shared diagnostic owns all vertex, index, texture, parameter, depth-load, +and color-sentinel bytes. The canonical builder copies those bytes into a +`FrameSnapshot`; the strict decoder returns owned values and accepts only this +draw shape. Tests mutate every represented family of state and verify that the +packet remains valid after caller storage is destroyed. + +The version-1 artifact is 3,436 bytes. It records fixture identity, case +metadata, bottom-left row order, all three storage-canonical color planes, and +canonical depth24. All three tolerances are zero. Publication writes a unique +sibling temporary file and installs it atomically without replacing an +existing destination. + +## OpenGL boundary and fail-closed behavior + +The pass-specific registry borrows one synchronous run's viewer buffers, +textures, target, sampler description, and shader. It owns no GL object. The +executor resolves exact handle generations and validates the complete packet, +live buffer metadata and bytes, live texture formats/extents/mips, distinct GL +names, target attachments, sampler policy, shader identity and reflection, and +vertex-array state before binding or clearing the destination framebuffer. + +The account-free harness allocates independent direct and contract resource +sets. Both paths strict-decode the same immutable `FrameSnapshot`. Before each +submission it poisons the other framebuffer, shader, texture and sampler +bindings, vertex and index bindings, attribute divisors, viewport, scissor, +color and depth masks, depth and cull state, polygon state, blend state, and +all four output sentinels. GL errors bracket both submissions. + +Twenty-eight rejection cases cover stale generations for every resource +family, wrong program and variant, descriptor-format and extent claims, packet +layout and ranges, parameter size, sampler policy, and genuinely incompatible +live buffers, index topology, texture storage, mip chains, and color/depth +targets. Every rejected execution leaves the selected target's four sentinels +unchanged. + +The diagnostic startup selector is parsed before viewer construction. It +matches the viewer's direct-flag and composing `--set` precedence, accepts +`Global.`-qualified controls, honors false and invalid boolean values, and +stops at `--`. A selected diagnostic without `SECONDLIFE_USER_DIR` fails before +profile initialization. Linux also creates and write-probes that isolated root +before construction, and the SDL cleanup callback tolerates this early exit. + +## Fixed-input result + +Native Apple OpenGL produced this result: + +```text +MATERIAL_CONTRACT_PARITY result=pass case=nonrigged_normspec_indexed shader_index=12 shader_class=3 components=832 mismatches=0 max_abs_delta=0 depth_passes=5 depth_failures=4 mirror_clipped_passes=4 rejection_cases=28 rejection_failures=0 artifact=written +``` + +The 832 compared values are 256 components from each of three color planes and +64 depth values. Every color plane contains multiple written storage values. +The fixture also proves five depth passes, four depth failures, and four +mirror-clipped passing samples, preventing a clear-only, missing-draw, disabled +depth, or disabled clipping result from passing. + +## Code size + +| Code | Size | +| --- | ---: | +| Backend-neutral builder and decoder | 555 lines | +| Shared fixture, artifact, and comparison | 879 lines | +| OpenGL registry and executor | 1,034 lines | +| Account-free GL harness | 1,332 lines | +| Early diagnostic argument parser | 165 lines | +| New focused diagnostic, registry, and argument tests | 473 lines | + +The 1,034-line OpenGL adapter is the migration-cost signal for this slice. +Much of it is deliberate live-object and shader reflection validation needed +to prove that the packet describes what the existing viewer actually submits. +It is not a general RHI and it is not routed into production draw pools. + +## Verification + +- Linux and macOS each passed 26 render-contract cases, four material artifact + cases, two GL registry cases, and five startup argument cases. +- GCC accepted the final OpenGL executor and Linux startup translation unit as + C++20 with the project's warnings treated as errors. +- The benchmark's 57 Python tests passed. Both edited settings XML files parsed + successfully; the tests also validated all schema-3 manifests and the + checked-in fixture. +- The default universal Release viewer built on macOS. Its executable contains + both `x86_64` and `arm64` slices. +- Five native missing-isolation invocations, including qualified and attached + `--set` forms, returned the intended material or tonemap failure marker before + viewer construction. +- The native material replay passed with 832 exact values, nontrivial depth and + clipping coverage, and 28 clean fail-closed cases. Its disposable profile, + artifact, and log were removed immediately afterward. +- Whitespace validation passed. An adversarial source review was run throughout + the stage. + +A final whole-viewer Linux link is not claimed. The existing GCC 15 build tree +repeatedly terminated with compiler bus errors in unrelated common/filesystem +translation units during earlier clean attempts. The final Stage 12 Linux +files have warning-as-error syntax coverage and their 37 focused cases pass; +the default Mac build supplies the complete ordinary-build check. This +toolchain limitation should be retired with a stable Linux build environment, +but it does not change the packet or the native GL result. + +The executor also trusts the protected FBO wiring of the internally-created +`LLRenderTarget` after validating its metadata and all seven attached or +sampled texture objects. The registry must gain direct attachment introspection +before it can safely borrow externally mutable targets. That is a reuse +constraint, not a gap in this lexical harness, whose targets are private to one +synchronous run. + +## Reanalysis + +The indexed material draw now has a deterministic OpenGL oracle. Master Stage +1 still requires its Vulkan executor, and the streaming upload slice remains +validation-only. The next committable stage is therefore a standalone Vulkan +replay of this exact packet and artifact. + +Stage 13 should keep Vulkan opt-in and process-isolated. It should compile +wrappers around the existing class 3 material shader math, build Vulkan +buffers, images, sampler, three color attachments, and depth from the shared +fixture, and consume the same `FrameSnapshot`. A strict Vulkan registry must +reject packet and live-resource mismatches before command-buffer recording or +image mutation. Native Linux RADV and macOS MoltenVK runs must produce the +version-1 material artifact, compare it against the OpenGL reference, and +finish with clean validation. Ordinary viewer builds must remain Vulkan-free. + +Streaming uploads, presentation, packaging, runtime selection, a whole-frame +Vulkan path, and performance measurement remain later stages. diff --git a/doc/performance/renderer-stage-13-decision.md b/doc/performance/renderer-stage-13-decision.md new file mode 100644 index 00000000000..2fda665b1b7 --- /dev/null +++ b/doc/performance/renderer-stage-13-decision.md @@ -0,0 +1,193 @@ +# Stage 13 offscreen Vulkan material replay decision + +## Decision + +Keep the standalone Vulkan material replay as a migration probe, but mark the +Stage 13 parity gate blocked. Both native Vulkan implementations execute the +unchanged Stage 12 packet correctly, pass every internal coverage and +fail-closed gate, and finish with no validation messages. Neither produces an +artifact that is bit-exact with Apple OpenGL under the frozen zero-tolerance +policy. + +The result does not justify changing the packet, adding a driver-specific LOD +bias, or widening the tolerances after observing output. Vulkan remains +test-only and the production viewer remains OpenGL. Master Stage 1 continues +with its independent streaming-upload slice; the material mismatch stays a +known input to later parity-policy design. + +## Frozen mapping + +The Vulkan diagnostic consumes the unchanged Stage 12 material +`FrameSnapshot`, fixture fingerprint, and schema-1 artifact. Backend-specific +mapping is limited to API conventions and live Vulkan objects: + +| Contract field | Vulkan mapping | +| --- | --- | +| Seven planar vertex streams | Bind the same 304-byte allocation seven times at packet offsets, with explicit locations 0 through 6 and the packet formats and strides. | +| Six 16-bit indices | Validate the canonical `{0, 1, 2, 0, 2, 3}` bytes, then bind `{2, 0, 1, 3, 0, 2}`. Each cyclic permutation preserves geometry, winding, and interpolation while making Vulkan's first provoking vertex equal OpenGL's last provoking vertex for the flat tangent sign. | +| Vertex clip position | Run the existing `materialV.glsl` body, then map OpenGL clip depth with `z = (z + w) * 0.5`. A negative-height viewport preserves the packet's bottom-left convention. | +| Cull and front face | Back-face culling remains enabled. With the negative-height viewport, Vulkan's signed-area definition still classifies the contract triangles as counter-clockwise, so the Vulkan pipeline keeps `VK_FRONT_FACE_COUNTER_CLOCKWISE`. The cyclic index rotation preserves that winding. | +| 272-byte parameter packet | Bind the unchanged 68 words as one `std140` array of 17 `vec4` values at descriptor set 0, binding 0. Declaration-wrapper accessors reconstruct the existing shader uniforms without repacking or copying material equations. | +| Three sampled mip chains | `VK_FORMAT_R8G8B8A8_UNORM`, three levels, combined image samplers at set 1 bindings 0 through 2, linear minification/magnification/mip filtering, repeat addressing, and 8x anisotropy. | +| Three color targets | Two `VK_FORMAT_R8G8B8A8_UNORM` attachments and one `VK_FORMAT_R16G16B16A16_UNORM` attachment, cleared to zero, stored, and left shader-readable. | +| Depth24 target | The sole allowed Vulkan storage substitution is `VK_FORMAT_D32_SFLOAT`. It must support depth attachment and transfer source/destination usage. Loaded and read values are canonicalized to the nearest depth24 storage code. No other depth format fallback is permitted. | +| Indexed draw | One triangle-list `vkCmdDrawIndexed` with six indices, one instance, `LEQUAL` depth testing and writes, fill mode, replacement color writes, and one sample. | +| Lifetime and synchronization | The standalone process owns every object. Sampled images enter shader-read layout before execution and depth enters and remains depth-attachment layout. Diagnostic sentinel snapshots leave color images in a truthful, tracked transfer-source layout; the packet's undefined initial state maps to a discard transition from that known layout into color-attachment layout, followed by the declared shader-read final layout. | + +The material sources remain the existing class 1 vertex and class 3 fragment +files selected by OpenGL shader 12 with `HAS_NORMAL_MAP=1`, +`HAS_SPECULAR_MAP=1`, and `DIFFUSE_ALPHA_MODE=0`. Vulkan-only files may add +declarations and coordinate mapping, but may not copy or replace the material +equations. + +## Frozen comparison policy + +All four artifact planes retain the Stage 12 zero tolerances. The comparator +first requires the exact schema, fixture fingerprint, case, dimensions, +formats, bottom-left row order, and component counts. RGBA8, RGBA16 UNORM, and +canonical depth24 values then compare as exact storage codes. A backend that +cannot satisfy this policy blocks the stage; observed output may not be used to +relax it. + +The valid Vulkan run must also retain nontrivial writes in every color plane, +both depth-pass and depth-fail samples, and mirror-clipped samples whose loaded +depth remains unchanged. Every rejected registry must produce no executor +command recording or queue submission, no target mutation, and no validation +message. + +## Deliberate exclusions + +This stage adds no surface, swapchain, presentation, viewer runtime backend, +production draw-pool route, general shader manifest, streaming upload path, +timing, account input, or live-world input. Vulkan remains behind the existing +test-only build option. + +## Native results + +A fresh Stage 13 Apple OpenGL run passed all 832 direct-versus-contract +components, five depth passes, four depth failures, four mirror-clipped passing +samples, and 28 rejection cases. Its artifact was byte-identical to the +independent Stage 12 reference. This proves that the Vulkan declaration guards +did not change the OpenGL oracle. + +The native Linux RADV replay passed the Vulkan-side gates: + +```text +VULKAN_MATERIAL result=pass case=nonrigged_normspec_indexed components=832 rejection_cases=39 recordings=1 submissions=1 depth_passes=5 depth_failures=4 mirror_clipped_passes=4 validation_messages=0 portability_enumeration=enabled portability_subset=not_advertised artifact=written +``` + +Its exact comparison failed with 92 of 832 components different and maximum +absolute error `0.120729387`. Eighty-seven color differences occur only in the +same nine written pixels: 27 components in G-buffer plane 0, 33 in plane 1, +and 27 in plane 2. Constant alpha channels, every sentinel pixel, fragment +coverage, attachment placement, and row order remain exact. Five depth values +differ by one depth24 storage code; the other 59 depth values are exact. + +The native Apple Silicon MoltenVK replay also passed the Vulkan-side gates: + +```text +VULKAN_MATERIAL result=pass case=nonrigged_normspec_indexed components=832 rejection_cases=39 recordings=1 submissions=1 depth_passes=5 depth_failures=4 mirror_clipped_passes=4 validation_messages=0 portability_enumeration=enabled portability_subset=enabled artifact=written +``` + +All 768 MoltenVK color components match Apple OpenGL. Its exact comparison +fails only on five depth values, each separated by one depth24 storage code; +the maximum decoded difference is `5.96046448e-08`. + +## Mismatch analysis + +No packet, descriptor, shader-interface, attachment, row-origin, winding, or +coverage error was found. The Vulkan and OpenGL paths use the same texture +bytes, mip extents, normalized coordinates, repeat addressing, linear filters, +trilinear mip selection, and 8x anisotropy. Reconstructing the written RADV +pixels shows Apple OpenGL selecting a smaller effective anisotropic footprint +than RADV. Vulkan deliberately leaves the particular anisotropic filtering +scheme implementation-dependent, so a device-specific correction would not +be a truthful API mapping. See the +[Vulkan texture operations specification](https://docs.vulkan.org/spec/latest/chapters/textures.html). + +The five depth differences are non-systematic one-code rounding differences +after D32 interpolation and depth24 canonicalization. Four written pixels are +depth-exact, all untouched depth values are exact, and MoltenVK reproduces the +same class of difference without the RADV color differences. This supports a +rasterization/interpolation precision explanation rather than a range or +orientation defect. It remains an evidence-backed inference, not a claim about +either driver's internal algorithm. + +The mapping review also corrected a pre-run assumption: a negative viewport +height does not require clockwise fronts for this fixture. Under Vulkan's +framebuffer signed-area definition the intended triangles are counter-clockwise, +and native back-face-culling coverage confirms `VK_FRONT_FACE_COUNTER_CLOCKWISE`. + +## Fail-closed boundary + +The registry validates the decoded packet and all registered Vulkan metadata +before the executor copies the parameter block or records a command. Thirty-nine +rejection cases cover stale generations, shader identities, buffer and image +metadata, view ranges, usages and layouts, descriptors, sampler policy, +attachment aliasing, render-pass and pipeline compatibility, vertex and index +state, parameter layout, and depth storage. + +Each rejected case proves zero executor recordings, zero queue submissions, +unchanged parameter bytes, unchanged four-plane sentinels, and no added +validation message. The only valid execution records once and submits once. +The registry describes resources created and owned by this synchronous +diagnostic; it is not general live Vulkan introspection. + +## Code size + +| Code | Size | +| --- | ---: | +| Vulkan registry and executor | 964 lines | +| Standalone runner and comparator | 2,824 lines | +| Declaration wrappers around shared shader math | 120 lines | +| Focused Vulkan registry tests | 219 lines | + +The 964-line registry and executor are the backend-adapter migration-cost +signal for this slice. The larger runner owns instance/device selection, +fixture upload, validation, rejection evidence, readback, and bounded reporting +that production infrastructure would eventually centralize. + +## Verification + +- The final Linux opt-in targets built with project warnings treated as errors. + The 26 render-contract, four material-artifact, two OpenGL registry, five + startup-argument, and four Vulkan registry cases all passed. +- Native RADV passed 39 rejection cases, one recording, one submission, + nontrivial color/depth/clipping gates, and zero validation messages. Exact + artifact comparison then failed under the frozen policy as reported above. +- The macOS runner and comparator built as universal `arm64`/`x86_64` + executables and passed strict code-signature checks. The native arm64 + registry passed all four cases. MoltenVK advertised portability subset, + passed all internal gates with zero validation messages, and produced the + five-code depth-only comparison failure. +- The fresh Stage 13 Apple OpenGL artifact matched the Stage 12 artifact byte + for byte. No account, live scene, or timing was used. +- With the Vulkan option disabled, the universal macOS and Linux Release + viewers built successfully. The Mac executable had no Vulkan loader linkage, + and the default Linux graph contained no Vulkan diagnostic targets. +- The benchmark harness's 57 Python tests passed, including settings XML, + schema-3 manifests, and the checked-in fixture. Shader compilation and + `spirv-val`, strict no-replace artifact publication, whitespace validation, + and adversarial source review passed. + +The ordinary Linux build initially exposed an incomplete ambient development +closure: GLX and X11 headers, the X11 link library, and `zlib.h` were absent. +Supplying libglvnd, libX11, xorgproto, and zlib through Nix completed the same +default graph and final viewer link without changing repository files. + +## Reanalysis + +Tonemap is dual-API. The indexed material slice now has an exact OpenGL oracle, +a native Vulkan executor, and a documented cross-implementation parity block. +The remaining master Stage 1 slice is streaming texture upload, which stresses +declared writes, staging, synchronization, ownership, and lifetime without +depending on material sampling or presentation. + +The next committable stage should define one immutable streaming-upload request +and replay it through the existing OpenGL upload mechanism in an account-free +diagnostic. It should freeze source bytes, mip/update regions, destination +format, generation, ordering, completion, and readback artifact; reject stale +or incompatible resources before mutation; and measure the OpenGL adapter +size. Vulkan upload, surfaces, timing, live asset fetching, and production +routing remain outside that stage. Reanalyze again only after that OpenGL +oracle is committed. diff --git a/doc/performance/renderer-stage-14-decision.md b/doc/performance/renderer-stage-14-decision.md new file mode 100644 index 00000000000..1c4a44047e7 --- /dev/null +++ b/doc/performance/renderer-stage-14-decision.md @@ -0,0 +1,201 @@ +# Stage 14 OpenGL streaming texture upload decision + +## Decision + +Keep the backend-neutral streaming-upload packet and the production-seam +OpenGL replay as the exact oracle for the next migration slice. The fixed +request now exercises decoded image admission, replacement allocation, base +upload, generated mips, sampling, completion publication, and retirement of +the prior generation without an account or live asset. + +Native OpenGL 4.1 on Apple Silicon and software OpenGL 4.1 and 4.5 on Linux +produced byte-identical schema-2 artifacts. Direct fixture execution and +packet execution also match exactly. This clears the stage to add a standalone +Vulkan implementation of the same immutable request. The production viewer +remains OpenGL. + +## Frozen request + +The request represents replacement generation `{11, 2}` of logical 32x16 +image `{11, 1}` at revision 23 after revision 22. Discard level 2 makes the +resident base 8x4. The source is RGBA8 in top-left row order with a 36-byte row +pitch: 32 pixel bytes followed by four poison bytes on every row. The executor +may normalize those bytes only after all packet and live-resource checks pass. + +| Contract field | Frozen value | +| --- | --- | +| Frame and revisions | Frame 1, prior revision 22, replacement revision 23 | +| Source | 8x4 RGBA8, top-left rows, 36-byte pitch, four poison bytes per row | +| Logical image | 32x16 with resident discard 2 | +| Destination | New persistent image generation `{11, 2}` replacing `{11, 1}` | +| Mips | Three levels: 8x4, 4x2, and 2x1; generate levels 1 and 2 | +| States | Undefined to transfer destination to shader read | +| Sampling | Fixed 4x2 RGBA8 output through the viewer copy program | +| Lifetime | One completion for revision 23 and one retirement of `{11, 1}` at frame 1 | + +The packet owns its source storage. Its image, sampler, pipeline, pass, +viewport, scissor, release, and handle generations are all canonical and +strictly decoded. The direct path receives the same decoded inputs rather than +a second hand-built interpretation. + +## OpenGL mapping + +The contract adapter resolves registered viewer objects, validates their live +metadata, and then calls the real `LLImageGL::createGLTexture()` upload seam. +It does not substitute a diagnostic-only `glTexImage2D` implementation. The +source is converted to tight bottom-left rows after preflight, uploaded as the +resident base, and passed through the viewer's normal mip generation. The +replacement is sampled with `gCopyProgram` into a 4x2 render target. + +The executor reads all three replacement mips and the sampled output. It +requires the base to equal the normalized source exactly, the sampled output +to equal mip 1 exactly, and every written plane to contain at least two changed +texels with different values. Only then may it publish the completion and +retirement ledger and expose an artifact. The old handle becomes unresolved, +the replacement becomes current and resolved, and the replacement GL object +stays owned by its existing viewer wrapper. + +The route borrows the active viewer context and therefore fails closed unless +it runs on the main thread, OpenGL is at least 4.1, shader profiling is off, +the registry is canonical, and all inspected GL objects match the packet. It +uses a dedicated scratch VAO and restores the caller's cached and raw GL state +before publishing lifecycle evidence. The captured surface includes program +and shader caches, uniform dirtiness, active and bound textures, samplers, +pixel buffers and stores, array and element buffers, VAO attributes, +framebuffers, draw/read buffers, viewport, scissor, color mask, polygon mode, +capabilities, clip distances, and Linux clip-control state when available. + +## Artifact and comparison policy + +The artifact is schema 2 and exactly 480 bytes. It records the fixed fixture +identity, request metadata, 168 bottom-left RGBA8 mip bytes, 32 bottom-left +RGBA8 sampled bytes, completion and retirement evidence, and before/after +resolvability. A big-endian 64-bit FNV-1a checksum covers the first 472 bytes. + +Encoding and decoding reject every noncanonical field. Comparison requires +identical metadata, lifecycle evidence, and all 200 image bytes; no tolerance +or driver-specific adjustment exists. Publication uses an unpredictable +sibling file and a no-replace atomic link. An existing destination is never +overwritten. Once the destination link is installed, failure to remove the +temporary sibling is a nonfatal warning because publication has already +succeeded. + +## Fail-closed boundary + +Fifty-nine rejection cases cover malformed packet fields, stale and aliased +handles, revisions and lifecycle state, image dimensions, mip counts and +formats, sampler and shader state, upload layout, output compatibility, +screen-triangle metadata, active-texture cache divergence, profiling, +framebuffer and texture parameters, and off-main-thread execution. + +Each rejected case retains the caller's result object, resource pixels, +lifecycle ledger, registry resolvability, and ambient GL state. The valid case +records exactly one completion and one retirement. Live format rejection uses +a complete RGB8 poison mip chain so the resource remains readable on Apple +OpenGL while still being incompatible with the RGBA8 contract. + +## Process isolation + +The hidden route requires `SECONDLIFE_USER_DIR` before ordinary startup can use +a profile. Linux prepares the isolated root and proves it writable with an +exclusive temporary file. Win32 uses the Unicode environment value, creates +the exact isolated root, and performs an exclusive write/delete-on-close +probe. Both reject a missing or invalid root before viewer initialization. + +The macOS global directory object now computes paths without creating profile +or cache directories. Directory creation is deferred to `initAppDirs()`, and +the Objective-C system-folder lookup is read-only. The native isolation test +proved that constructing the global object does not create the base or profile +tree and that explicit initialization creates only the supplied temporary +tree. The daily checkout and its normal profile were not modified. + +Win32 received source and adversarial review but no Windows runtime lane was +available. That remains a portability residual, not evidence of Windows +execution. + +## Native results + +The final Linux OpenGL 4.1 compatibility run reported: + +```text +TEXTURE_UPLOAD_CONTRACT_PARITY result=pass resident=8x4 logical=32x16 discard=2 mips=3 sampled=4x2 mip_bytes=168 sample_bytes=32 mismatches=0 rejection_cases=59 rejection_failures=0 completions=1 retirements=1 artifact=written +``` + +The final Linux OpenGL 4.5 compatibility run reported the same marker and +exercised clip-origin and clip-depth capture and restoration. Both artifacts +were 480 bytes. + +The final native Apple Silicon OpenGL 4.1 core run reported the same passing +marker and a 480-byte artifact. The first Apple run exposed the incomplete +mixed-format poison chain described above; it failed one rejection gate and +published no artifact. After preserving mip-chain completeness, all 59 gates +passed. The failed run was discarded rather than counted as evidence. + +The three accepted artifacts had the same SHA-256 digest: + +```text +50b10b206d54e48fdc9fef7cb4554169e15b0cfb1dc8a8773d271e6bf7ccc0ea +``` + +No timing was captured or retained. The route did not log in, fetch a live +asset, enter a region, or use the temporary demo account. + +## Code size + +| Code | Size | +| --- | ---: | +| OpenGL registry, executor, and public seam | 1,484 lines | +| Viewer parity route and fixture execution | 1,588 lines | +| Neutral packet and artifact implementation | 1,253 lines | +| Focused packet, artifact, and registry tests | 780 lines | + +The 1,484-line OpenGL adapter is the migration-cost signal for this slice. A +large part is explicit borrowed-context validation and state restoration that +a future backend-owned command path would centralize. The standalone parity +route owns rejection poisoning, dual execution, readback, and process-level +evidence; it is not proposed as production architecture. + +## Verification + +- The final Linux Release viewer relinked with project warnings treated as + errors. The 27 render-contract, three OpenGL upload registry, four upload + packet, four upload artifact, five startup-argument, and seven Linux + directory cases passed. +- The benchmark harness's 57 Python tests passed. Settings and command-line XML + parsed successfully, and `git diff --check` was clean. +- Native Linux OpenGL 4.1 and 4.5 passed all 59 rejection cases, exact direct + versus contract comparison, nontrivial-write gates, one completion, one + retirement, and fixed-size artifact publication. +- The full macOS ReleaseOS build succeeded for the universal app. The app + executable contains both `arm64` and `x86_64`; the focused tests ran natively + as arm64 and passed 27, three, four, four, five, and two sets of eight + directory-isolation cases. The locally built app had no Vulkan or MoltenVK + linkage. This development package was unsigned, so no signature result is + claimed. +- All 32 Stage 14 tracked paths used by the Mac build matched the Linux working + tree byte for byte. The ordinary Mac checkout remained at its original clean + commit. +- Independent portability and adversarial reviews ended with no concrete + correctness, GL-state, isolation, build-graph, or exit-gate findings. + +## Reanalysis + +The upload slice now has a neutral request, an exact cross-platform OpenGL +oracle, and explicit lifetime evidence. Unlike the blocked material slice, its +observable result is storage-code deterministic across Apple and Mesa OpenGL. +This makes it a strong next candidate for Vulkan because staging, image +transitions, mip generation, sampling, and retirement can be tested without a +swapchain or live scene. + +Stage 15 should implement the same packet in the existing test-only Vulkan +build. It should resolve registered Vulkan buffers, images, views, memory, +samplers, pipeline, and lifecycle state; reject incompatible live resources +before recording; upload the canonical source; generate the two remaining +mips; sample to the fixed 4x2 target; read back schema-2 bytes; and compare the +result exactly with the Stage 14 artifact. Rejected cases must record and +submit nothing and retain sentinels and lifecycle state. + +Stage 15 must not add a surface, swapchain, presentation path, production +backend selection, account input, live asset input, timing, tolerance changes, +or a general texture manager. Reanalyze the next production migration boundary +only after the standalone Vulkan result and exact comparison are committed. diff --git a/doc/performance/renderer-stage-15-decision.md b/doc/performance/renderer-stage-15-decision.md new file mode 100644 index 00000000000..e741e5a6e88 --- /dev/null +++ b/doc/performance/renderer-stage-15-decision.md @@ -0,0 +1,272 @@ +# Stage 15 Vulkan streaming texture upload decision + +## Decision + +Accept the standalone Linux Vulkan replay as an exact implementation of the +Stage 14 streaming-upload contract. Native Vulkan copied the frozen pitched +source, generated both lower mips with linear image blits, sampled the result +through the viewer copy shader, preserved the retired image, and produced the +same 480-byte schema-2 artifact as OpenGL. + +The macOS evidence lane was blocked by SSH connectivity before a fresh source +sync, build, or run could start. ICMP reached the host, but repeated TCP port +22 connections timed out before authentication. No remote state was changed. +This is an infrastructure block, not a Vulkan capability or byte-comparison +failure, and it does not support a cross-platform Vulkan conclusion. + +The production viewer remains OpenGL-only. The three master Stage 1 slices now +provide enough migration-cost evidence to stop extending standalone pass +runners and move to a production-facing, backend-neutral draw boundary. + +## Frozen request and artifact + +The packet, fixture, schema-2 artifact, and exact comparison policy are +unchanged from Stage 14. + +| Contract field | Frozen value | +| --- | --- | +| Frame and revisions | Frame 1, prior revision 22, replacement revision 23 | +| Source | 8x4 RGBA8, top-left rows, 36-byte pitch, four poison bytes per row | +| Logical image | 32x16 with resident discard 2 | +| Destination | New persistent generation `{11, 2}` replacing `{11, 1}` | +| Mips | Three levels: 8x4, 4x2, and 2x1 | +| States | Undefined to transfer destination to shader read | +| Sampling | Fixed 4x2 RGBA8 output through the viewer copy program | +| Lifetime | One revision-23 completion and one retirement of `{11, 1}` at frame 1 | + +The artifact remains 480 bytes: 168 bytes of bottom-left mip data, 32 bytes of +bottom-left sampled output, canonical metadata and lifecycle evidence, then a +big-endian 64-bit FNV-1a checksum. All 200 image bytes and all metadata must be +identical. No tolerance, bias, alternate source, or driver-specific rule was +introduced. + +## Vulkan resource mapping + +The executor requires Vulkan 1.1 and one queue family with graphics and +transfer support. It owns one queue, a reset-capable command pool, one primary +command buffer, and all resources used by the synchronous run. + +| Resource | Frozen Vulkan mapping | +| --- | --- | +| Screen triangle | Distinct 48-byte vertex buffer | +| Upload staging | Distinct 144-byte mapped transfer-source buffer | +| Replacement | 8x4 `VK_FORMAT_R8G8B8A8_UNORM`, three mips, transfer source, transfer destination, and sampled usage | +| Output | 4x2 `VK_FORMAT_R8G8B8A8_UNORM`, color attachment, transfer source, transfer destination, and sampled usage | +| Readback | Distinct 200-byte mapped transfer-destination buffer | +| Sampler | Linear minification, magnification, and mip filtering; clamp; LOD 0 through 2; anisotropy disabled | +| Raster state | Viewport `{0, 2, 4, -2, 0, 1}`, 4x2 scissor, no blend or depth | +| Final layouts | Replacement and output both shader read-only | + +The four upload regions copy one 8x1 row each from staging offsets 108, 72, +36, and 0. This converts the owned top-left source to Vulkan's image row order +without copying any poison byte. Two `vkCmdBlitImage` calls generate 4x2 and +2x1 mips. The sampled draw writes the 4x2 output. Readback starts at offsets 0, +128, 160, and 168 for the three replacement mips and the output. Only the +two-row output plane is flipped during artifact assembly. + +Format preflight requires sampled-image, color-attachment, transfer, and +linear-filter blit support before recording. All barriers use ignored queue +family indices because the run has one queue family. The replacement and +output begin with explicit undefined discard transitions, and the one valid +command buffer performs the copies, blits, render pass, readbacks, and final +shader-read transitions before one submission and one queue wait. + +## Pre-artifact mapping correction + +The first frozen mapping required the output image to end in shader-read layout +but omitted sampled usage. Khronos validation correctly rejected that +contradiction with `VUID-VkImageMemoryBarrier-newLayout-01213`. The run +published no artifact and retained no candidate bytes. + +Before collecting evidence, the mapping was corrected by adding +`VK_IMAGE_USAGE_SAMPLED_BIT` to the output image. This is the minimum truthful +capability for the packet's declared `After=ShaderRead` state. It did not +change the packet, source, shader, LOD policy, readback, artifact, or exact +comparison rule. + +## Shader reuse and identity + +Small stage wrappers compile the existing viewer `copyV.glsl` and +`copyF.glsl` bodies for Vulkan. Source guards provide the wrapper-controlled +version and interface declarations while leaving the viewer's ordinary +OpenGL compilation unchanged. The descriptor is one combined image sampler, +and the fragment body still performs the implicit-LOD copy used by Stage 14. + +The final Vulkan 1.1 SPIR-V modules passed `spirv-val`. Their SHA-256 identities +were: + +```text +vertex 139f3d06e998cdd95ad6ae751dd97cf7ecaeb9c210efca379a7b1ee73270789c +fragment 2d07ec80932a25934493be1d4f8bdfb3ca3d2bac0cf3ffa9cbb7d7520bdaafb1 +``` + +The registry matches these full identities rather than trusting filenames or +nonzero shader modules. + +## Registry, ownership, and lifetime + +`LLRenderVulkanTextureUpload` resolves the packet through a pass-specific +registry. It validates exact generations and every relevant native property: +sizes, formats, mip counts, usage, memory properties and ranges, image views, +layouts, descriptors, render-pass compatibility, shaders, vertex input, +viewport and scissor policy, raster state, command objects, queue family, and +lifecycle ledger. + +Each run receives a unique nonzero ownership token from a monotonic atomic +allocator. Every buffer, image, sampler, pipeline, and execution context must +carry that same token. Native handles, image views, memory allocations, and +the three buffer allocations are also checked for the required disjointness. +This prevents a registry assembled from valid objects owned by different +runners from passing preflight. + +The old image remains a fixed 168-byte sentinel. A queue-idle readback before +execution must match it. Preflight then proves that old, replacement, and +output images, views, and memory are pairwise distinct. The sole executor +command buffer never names the old image, so that structural proof is complete +before the local lifecycle ledger is published. A second queue-idle snapshot +after execution must also match before the runner may publish the artifact. + +There is a deliberate evidence-order distinction here. The fixed 200-byte +executor readback cannot include another old-image snapshot without changing +the frozen artifact path, so the local logical ledger changes after structural +proof and before the runner's external post-execution snapshot. Durable +artifact publication still waits for that snapshot. A failed post-snapshot +therefore cannot publish durable evidence, although the already completed +local run is not transactionally rolled back. + +## Fail-closed boundary + +The runner exercises all 32 Stage 14 packet mutations, six focused packet and +draw-shape supplements, and the Vulkan registry, native-object, execution +context, and lifecycle mutations. The final matrix contains 111 rejection +cases. Preflight rejections preserve staging and readback sentinels, image +bytes and layouts, result state, lifecycle state, registry resolution, +recording and submission counters, and validation count. + +The valid case records one executor recording attempt, one submission, one +completion, and one retirement. Registry resolution and physical-object +checks occur before mapped staging memory changes or either execution counter +increments. + +The guarantee narrows after successful preflight. A device failure during +recording, submission, waiting, or readback prevents logical result and +artifact publication, but the diagnostic does not promise transactional +rollback of scratch buffers or GPU image state. This distinction avoids +claiming stronger recovery than a synchronous Vulkan command stream can +provide. + +## Native result + +The final native Linux RADV run with Khronos validation reported: + +```text +VULKAN_TEXTURE_UPLOAD result=pass rejection_cases=111 recording_attempts=1 submissions=1 mip_bytes=168 sample_bytes=32 mismatches=0 completions=1 retirements=1 validation_messages=0 portability_enumeration=enabled portability_subset=not_advertised artifact=written +``` + +A fresh Stage 14 OpenGL oracle ran on Linux OpenGL 4.5 compatibility and +reported all 59 rejection cases, one completion, one retirement, zero +mismatches, and a written 480-byte artifact. The standalone comparator then +reported: + +```text +TEXTURE_UPLOAD_COMPARE result=pass mip_bytes=168 sample_bytes=32 mismatches=0 +``` + +Both artifacts had the Stage 14 digest: + +```text +50b10b206d54e48fdc9fef7cb4554169e15b0cfb1dc8a8773d271e6bf7ccc0ea +``` + +No timing was captured or retained. The stage did not log in, fetch a live +asset, enter a region, or use account credentials. + +## macOS infrastructure block + +Repeated fresh SSH probes reached the Mac by ICMP but timed out on TCP port 22 +before authentication. The host was not modified, and no Stage 15 source, +generated shader, build, profile, log, or artifact was copied there. The last +known Stage 14 daily checkout state is therefore not fresh Stage 15 evidence. + +Source review confirmed that the opt-in CMake path still selects the existing +universal Vulkan loader arrangement, shader compiler, SPIR-V validator, +validation layers, and MoltenVK ICD handling. That review supports build-graph +plausibility only. It is not a substitute for an Apple Silicon compile, +MoltenVK run, old-image preservation check, or artifact comparison. + +## Production isolation + +The Vulkan runner, comparator, registry test, and shader targets remain behind +the existing opt-in diagnostic switch. With that switch off, the default +CMake target graph exposes none of the new targets. The Linux Release viewer +target built successfully, and its executable has no Vulkan or MoltenVK +dynamic dependency. + +No surface, swapchain, presentation path, backend selection, live viewer draw, +or production texture manager changed. The shared neutral lifecycle type is a +name cleanup; the Stage 14 OpenGL name remains an alias and its behavior is +unchanged. + +## Code size + +| Code | Size | +| --- | ---: | +| Vulkan registry and executor | 1,486 lines | +| Standalone Vulkan runner | 2,252 lines | +| Comparator and shader wrappers | 98 lines | +| Focused Vulkan registry tests | 519 lines | + +The 1,486-line adapter is the useful migration-cost signal. The 2,252-line +runner also repeats device selection, validation, allocation, shader loading, +pipeline creation, command setup, rejection poisoning, readback, and artifact +publication. Those services should not be copied into another pass runner. + +## Verification + +- The opt-in Linux targets built with project warnings treated as errors. +- Vulkan shader compilation and Vulkan 1.1 SPIR-V validation passed. +- Native RADV and Khronos validation passed 111 rejection cases with one + recording, one submission, zero validation messages, and a 480-byte + artifact. +- A fresh OpenGL oracle and the independent comparator proved exact equality + with the frozen Stage 14 artifact. +- Focused C++ suites passed 27 render-contract, three OpenGL upload registry, + four upload packet, four upload artifact, six Vulkan upload registry, five + startup-argument, and seven directory-isolation cases. +- The benchmark harness's 57 Python tests passed. Settings and command-line XML + parsed successfully, both shaders passed `spirv-val`, and `git diff --check` + was clean. +- Missing arguments, an occupied destination, and a dangling destination + symlink all failed without replacing or changing the destination. +- Two independent adversarial reviews ended with no remaining concrete Vulkan + correctness, lifetime, artifact, shader, CMake isolation, or MoltenVK + findings. + +## Reanalysis + +Master Stage 1 has now measured three different pass boundaries. Tonemapping +can match OpenGL and Vulkan exactly. The material packet is structurally valid +but exposes a frozen cross-driver precision block. Streaming upload matches +exactly on Linux and exercises explicit transfer, layout, sampling, and +lifetime behavior; its fresh macOS lane remains blocked by connectivity. + +The common result is architectural rather than a performance number. Narrow +immutable packets work, exact or explicitly blocked artifacts keep evidence +honest, and pass-local native registries can reject stale resources. The cost +comes from rebuilding ownership, context validation, shader identity, command +services, and publication machinery around each standalone fixture. A fourth +diagnostic pass would mostly measure the same duplication. + +The next commit should begin master Stage 2 at the production draw seam. It +should translate one real `LLDrawInfo` shape into an immutable, +backend-neutral packet with copied draw ranges, constants, texture and +material references, and a stable pipeline key. It should prove that the +packet survives source mutation and retirement without carrying OpenGL or +Vulkan handles. Existing OpenGL submission should remain unchanged until that +boundary is canonical and tested. + +Shader manifests, shared Vulkan allocation and command services, descriptors, +pipeline caches, deferred destruction, surfaces, swapchains, presentation, +and backend selection should follow in later commits. Combining them with the +first production packet would hide which ownership boundary is responsible +for a failure. diff --git a/doc/performance/renderer-stage-16-decision.md b/doc/performance/renderer-stage-16-decision.md new file mode 100644 index 00000000000..4fd248bb988 --- /dev/null +++ b/doc/performance/renderer-stage-16-decision.md @@ -0,0 +1,201 @@ +# Stage 16 production material draw snapshot decision + +## Decision + +Accept the dormant production translation boundary for one ordinary-world, +non-rigged, opaque `PASS_NORMSPEC` draw. The adapter snapshots a real +`LLDrawInfo` into an owned, backend-neutral packet with generation-tagged +resource handles and a complete logical pipeline key. It is compiled into the +normal viewer but has no call site, so the existing OpenGL shader selection, +binding order, and indexed draw remain unchanged. + +This is the first committable slice of master Stage 2. It proves that prepared +renderer work can outlive the mutable viewer objects from which it was built. +It does not provide a production resource registry, pin resource lifetime, +compile a shader variant, submit a draw, select Vulkan, or make a performance +claim. + +## Stage 17 profile erratum + +Post-commit shader inventory found that variant 0 and its three color targets +describe the Stage 12 parity diagnostic, where emissive output and shadow +assembly are disabled. They are not the viewer's normal production defaults. +Production enables a fourth emissive target and uses a distinct shader variant +and one of two platform target profiles. Stage 16 has no adapter call site, so +the mistaken label had no runtime effect. Stage 17 corrects the key taxonomy +and retains this section as a historical erratum rather than rewriting the +Stage 16 implementation result. + +## Supported production source + +Capture receives the external render-map type, one `LLDrawInfo`, explicit +frame and pass identities, an ordinary-world render domain, a deferred-material +submission kind, a canonical logical pipeline key, and an injected resolver. +Only `PASS_NORMSPEC` is accepted. + +| Source or context | Packet mapping or policy | +| --- | --- | +| `mVertexBuffer` | Resolve distinct vertex and index handles, exact live sizes and counts, and UInt16 or UInt32 index type | +| `mStart`, `mEnd` | Copy as inclusive minimum and maximum vertex indices | +| `mOffset`, `mCount` | Copy in index-element units as first index and index count | +| Diffuse, normal, and specular textures | Resolve three image, sampler, and subresource records by role | +| `mModelMatrix` | Copy all 16 values; null becomes identity | +| `mTextureMatrix` | Copy all 16 diffuse texture-transform values; null becomes identity | +| `mSpecColor` | Copy RGBA/specular-exponent values | +| `mEnvIntensity`, `mAlphaMaskCutoff` | Copy validated unit-range constants | +| `mFullbright` | Normalize to emissive brightness 0 or 1 | +| `mTextureList` | Allow only null entries or the same diffuse source; distinct texture-array inputs fail | +| Render context | Require ordinary world rendering; HUD, impostor, reflection, cube, and invalid domains fail | + +The source must carry the exact position, normal, diffuse UV, normal UV, +specular UV, color, and tangent vertex attributes. The adapter rejects a +missing legacy material, GLTF material state, avatar or skin state, glow, +distinct texture-array batching, and non-null normal-map or specular-map +matrices. Those two auxiliary matrices have no assignment or consumption site +in the selected production path, so the adapter does not invent semantics for +them. + +External `PASS_NORMSPEC` identity is the opaque-mode authority. The adapter +does not copy `mShaderMask`, pointer-derived identity, an OpenGL cache hash, or +a shader object into the packet. + +## Owned packet and logical key + +The neutral packet lives under `llrender` and contains only values. It owns +frame and pass identities, distinct buffer and pipeline handles, three sampled +image records, exact indexed ranges, copied matrices and constants, index type, +and the complete pipeline key. It retains no viewer pointer, `LLPointer`, +reference, callback, span, GL name, Vulkan handle, or mutable shared storage. + +The canonical key names existing program semantics as +`deferred.material.normspec`, variant 0. It fixes the legacy material vertex +layout, triangle-list topology, back-face culling, counter-clockwise front +faces, depth test and write with less-or-equal comparison, one sample, three +unblended full-write targets in RGBA8, RGBA8, and RGBA16 order, and Depth24. +Structural equality and a pure validator cover every represented field. + +Vertex and index handles must be distinct. The packet has no binding-base +offsets with which one logical buffer could truthfully describe two different +stream layouts. Images may intentionally be reused across descriptor roles; +when one source texture fills more than one role, every resolution must return +the same image identity while role-specific samplers and ranges may differ. + +## Resolver and lifetime boundary + +The newview resolver interface is deliberately injected and side-effect-free +from the adapter's perspective. A successful lookup promises the source's +current live generation and validated immutable metadata. Missing, retired, +stale, undersized, malformed, or policy-incompatible records return no value. +All source and resource resolution completes before a packet can be returned. + +The deterministic fake resolver distinguishes current records from candidate +records and models explicit retirement. Focused cases reject nonzero stale +buffer, image, sampler, and pipeline generations as well as zero generations, +missing resolutions, retired records, undersized storage, invalid subresource +ranges, and inconsistent same-source image identities. + +The packet copies handle values but does not pin the resources they name. +Keeping a generation alive through submission, advancing generations only +after completion, and deferring destruction are responsibilities of the later +production registry and fence service. Stage 16 proves snapshot ownership, not +registry lifetime. + +## `LLDrawInfo` extraction + +The constructor, range validation, debug color, and skin-hash methods moved +byte-equivalently from `llspatialpartition.cpp` into a focused +`lldrawinfo.cpp` translation unit so the adapter test does not link the whole +spatial pipeline. The production destructor remains in +`llspatialpartition.cpp` with its original `gDebugGL` reference check. Only +the focused test target supplies a default destructor shim, so normal viewer +behavior is preserved even if that debug callback becomes active later. + +## Ownership and rejection evidence + +The canonical case checks exact frame, pass, ranges, typed handles, descriptor +roles, matrix storage order, scalar values, full pipeline key, and deterministic +repeat translation. Identity matrices are covered separately. + +After capture, the ownership case advances every fake buffer, image, sampler, +and pipeline generation; mutates ranges, constants, brightness, and both +pointer-backed matrices; then releases the draw, material, textures, and +vertex buffer. The saved packet remains equal to its original value and still +passes neutral validation. + +The compact rejection matrix also covers wrong pass, wrong submission kind, +special render domains, missing resources and material, GLTF, avatar and skin +state, glow, unsupported auxiliary matrices, distinct texture batching, +missing vertex attributes, inverted and out-of-bounds draw ranges, per-stream +aliasing, 16-byte attribute padding, UInt16 and UInt32 index storage, +non-finite or out-of-range constants, invalid image ranges, and shared-source +identity conflicts. + +## Production isolation + +There is no adapter call site. The material draw pool, global pipeline, +OpenGL bindings, and submission code were not edited. The default Linux +Release viewer relinked with project warnings treated as errors, and its +executable has no Vulkan or MoltenVK dynamic dependency. The default build +graph still has the existing opt-in Vulkan diagnostic switch disabled. + +The focused adapter test creates CPU-side viewer objects and opens no GL +context. A target-private texture implementation and destructor shim avoid +GPU allocation and spatial-pipeline linkage. The neutral packet files have no +newview, OpenGL, or Vulkan dependency, and the production adapter adds no +direct GL call. + +A bounded macOS SSH attempt timed out on port 22 before authentication. No +source sync, build, test, or machine change occurred. This is an infrastructure +block, not a C++ portability failure; no universal macOS build is claimed for +Stage 16. + +No account, live region, asset, benchmark, GPU artifact, or timing was used. + +## Code size + +| Code | Size | +| --- | ---: | +| Neutral packet, key, builder, and validator | 318 lines | +| Production `LLDrawInfo` implementation and adapter | 350 lines | +| Focused neutral and adapter tests plus test shim | 883 lines | + +The adapter remains narrow. The resolver is an interface rather than a hidden +partial registry, and the packet does not absorb shader assembly, allocation, +descriptor, command, or synchronization services. + +## Verification + +- The default Linux Release viewer relinked successfully with warnings treated + as errors. +- The draw-packet contract passed 4 of 4 cases, the adapter passed 7 of 7, and + the existing render contract passed 27 of 27. +- All 57 Python benchmark-harness tests passed. Settings and command-line XML + parsed successfully. +- Clang-format dry-run, `git diff --check`, dependency scans, direct-GL scans, + and the executable linkage check passed. +- An independent adversarial review ended with no remaining correctness, + ownership, range, layering, CMake, or production-behavior findings. + +## Reanalysis + +The source-to-packet ownership boundary is now stable, but the logical +`deferred.material.normspec` key still has no production description of how +source modules, feature defines, descriptor bindings, vertex locations, and +parameter layout form that program. Implementing a production registry first +would force it to resolve a pipeline whose shader identity and binding +contract remain implicit. + +Stage 17 should therefore add one backend-neutral shader manifest for the +canonical non-rigged opaque normal-and-specular variant. It should map the +logical key to the existing class-3 material shader assembly, ordered feature +fragments and defines, explicit descriptor and vertex locations, the consumed +parameter layout, and the already validated standalone Vulkan module +identities. A strict validator should reject reordered, missing, duplicate, +or backend-incompatible declarations. + +Stage 17 should remain dormant and production-compiled. It must not create the +resource registry, compile shaders at viewer runtime, add a Vulkan dependency +to the default executable, route a draw, or change the existing OpenGL shader +manager. Once the manifest is canonical and tested, the following stage can +implement the first production registry against an explicit pipeline contract +instead of reverse-engineering implicit shader state during lookup. diff --git a/doc/performance/renderer-stage-17-decision.md b/doc/performance/renderer-stage-17-decision.md new file mode 100644 index 00000000000..9af09470541 --- /dev/null +++ b/doc/performance/renderer-stage-17-decision.md @@ -0,0 +1,218 @@ +# Stage 17 material shader profile and manifest decision + +## Decision + +Accept the corrected material profile taxonomy and the backend-neutral manifest +for the existing three-output diagnostic shader. The production draw adapter +now accepts either of the viewer's four-target production profiles. The +diagnostic OpenGL and Vulkan shader recipes are exact and reflectable, but both +production profiles deliberately resolve no shader artifact in this stage. + +This is the second committable slice of master Stage 2. It corrects a label +before it can become runtime behavior and makes the proven shader interface +machine-checkable. It does not create a production shader, registry, parameter +materializer, Vulkan pipeline, command path, backend selector, or performance +result. + +## Corrected profile boundary + +Stage 16 used variant 0 and three color targets as its canonical key. Source +inventory showed that this is the Stage 12 parity diagnostic, where emissive +output and shadow assembly were intentionally disabled. It is not the normal +viewer configuration. The Stage 16 adapter has no call site, so the error had +no runtime effect. Its decision record now carries an explicit erratum rather +than rewriting the committed history. + +The typed shader variant uses one emissive bit and a two-bit shadow-assembly +field. Decoding rejects unknown bits and enum values. Pipeline validation then +accepts only these exact combinations: + +| Profile | Encoded variant | Emissive | Shadow assembly | Ordered color targets | Depth | +| --- | ---: | --- | --- | --- | --- | +| Diagnostic | 0 | Disabled | Disabled | RGBA8, RGBA8, RGBA16 UNORM | Depth24 UNORM | +| Modern HDR | 5 | Enabled | Sun and spot | RGBA8, RGBA8, RGBA16 UNORM, RGB16 float | Depth24 UNORM | +| Compatibility | 5 | Enabled | Sun and spot | RGBA8, RGBA8, RGB10A2 UNORM, RGB8 UNORM | Depth24 UNORM | + +Production defaults come from `RenderEnableEmissiveBuffer=1` and +`RenderShadowDetail=2`. The modern or compatibility target family is selected +by HDR enablement and the OpenGL capability check. `RGB16Float` was appended to +the neutral pixel-format enum so existing serialized enum values did not move. + +The normal viewer adapter requires a production key from its injected pipeline +resolver. It accepts the two target families above and rejects the diagnostic +key even if a resolver offers it. The neutral packet validator still recognizes +the diagnostic key because the standalone parity evidence remains valid. + +## Diagnostic OpenGL manifest + +The OpenGL manifest records the class 1 material vertex source, class 3 +material fragment source, and the 18 feature objects in their exact attachment +order and resolved shader classes. The proven diagnostic uses class 1 feature +objects except class 3 `reflectionProbeF.glsl`; its disabled SSR setting +resolves `screenSpaceReflUtil.glsl` at class 1. Its permutations are +`DIFFUSE_ALPHA_MODE=0`, `HAS_NORMAL_MAP=1`, and `HAS_SPECULAR_MAP=1`. It +contains no emissive or shadow permutation. + +The manifest records the viewer's sparse legacy vertex locations for position, +normal, three texture coordinates, color, and tangent; the three material +sampler channels; and three logical G-buffer outputs. The linked fragment +declaration remains `frag_data[4]`, with its fourth element explicitly marked +inert for this diagnostic. It also records the linked `ReflectionProbes` +uniform-block baggage at binding 0, 49,248 bytes, and its 12 active members. +That block is not a packet input, but a different linked interface cannot be +silently treated as the proven shader. + +## Diagnostic Vulkan manifest + +The Vulkan recipe records the two Stage 13 wrappers and their three shared +shader includes in compile order. Vertex locations are dense from 0 through 6. +Eight interstage values occupy locations 0 through 7, with `vary_sign` flat at +location 2. The same 272-byte parameter block is visible to both stages at set +0, binding 0. Diffuse, normal, and specular combined-image samplers occupy set +1, bindings 0 through 2. The fragment module declares exactly three outputs. +There are no push constants, linked OpenGL blocks, shadow descriptors, or +unlisted resource categories. + +The generic validator checks owned strings and arrays, known enums, unique +names, semantics, roles, locations, bindings, stage visibility, contiguous +parameter words, bounded output declarations, collision-free descriptor +coordinates, and non-overlapping push-constant ranges. Exact diagnostic +validation additionally compares all ordered values with the canonical recipe. +Reordering a source or define can therefore remain structurally valid while +failing canonical identity. + +Production Modern HDR and Compatibility keys return no manifest for either +backend. This prevents the three-output artifact from acquiring a production +label through fallback behavior. + +## Logical parameter provenance + +The manifest describes all 68 words consumed by the existing standalone +Vulkan material block. It does not claim that the dormant production adapter +already materializes them. + +| Words | Shader name | Provenance | +| --- | --- | --- | +| 0 to 15 | `modelview_matrix` | Derived from draw and frame state | +| 16 to 31 | `modelview_projection_matrix` | Derived from draw and frame state | +| 32 to 40 | `normal_matrix` | Derived from draw and frame state | +| 41 to 56 | `texture_matrix0` | Copied draw state | +| 57 to 60 | `specular_color` | Copied draw state | +| 61 to 64 | `clipPlane` | Frame or pass state | +| 65 | `env_intensity` | Copied draw state | +| 66 | `emissive_brightness` | Copied draw state | +| 67 | `mirror_flag` | Fixed diagnostic default 0 | + +Alpha cutoff is absent because opaque alpha mode does not consume it. Future +production materialization must derive the three matrix families and clip +state before submission rather than hiding them behind mutable globals. + +## Generated reflection evidence + +The existing opt-in Vulkan build now compiles and validates both Stage 13 +material modules before reflecting them with `spirv-cross` and disassembling +them with `spirv-dis`. A small manifest-linked executable emits the expected +interface as deterministic JSON. A standard-library-only checker then verifies +entry points, vertex and interstage I/O, descriptor names and cardinality, +set/binding coordinates, block size and stage union, fragment outputs, +resource-category absence, push-constant absence, and the exact flat +decorations. Hashes are written only after this gate passes, and the stamp is +written last. + +The focused checker test proves one valid fixture and rejects swapped stages, +descriptor arrays, unexpected push constants, missing flat decorations, +unexpected smooth-interface qualifiers, and interpolation decorations on +unlocated data. Swapping the real vertex and fragment reflection also failed +on their entry point modes. + +The fresh Linux build produced these build-local module identities with +glslang 16.4: + +```text +vertex b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095 +fragment e653afd5a5d541888cba40806c6b6f922db20ec2eb9414661893d8e29521f6ba +``` + +Compiler versions are not pinned, so these hashes are evidence for this build, +not source-controlled canonical constants. Generated modules, reflection, +disassembly, manifests, hashes, and stamps are removed after verification. + +## Production isolation + +Shader reflection, disassembly, and dumping exist only below the default-off +`LL_VULKAN_TONEMAP_TEST` switch. With that switch off, the default graph has no +material shader or manifest-dump target. The Linux Release viewer relinked and +has no Vulkan or MoltenVK dynamic dependency. + +A disposable macOS 26.6 Apple Silicon snapshot then completed the universal +`arm64`/`x86_64` ReleaseOS build with tests enabled and both Vulkan and renderer +benchmark options disabled. The final changed source bytes were overlaid and +the default build repeated before the four focused runners passed. The viewer, +all focused tests, and all 367 Mach-O files in the bundle had no Vulkan or +MoltenVK linkage; the bundle also contained no file named for either loader. +The disposable source, build, package, and Python environment were removed. + +Read-only preflight and final checks observed that another operation +fast-forwarded the ordinary Mac checkout from its previous Stage 10-era commit +to the shared Stage 16 commit during this lane. The disposable build did not use +that checkout as a configure or build directory and did not alter or revert the +external fast-forward. + +There is still no adapter call site. The material draw pools, OpenGL shader +manager, bindings, and submission code are unchanged. The neutral manifest has +no newview, OpenGL, or Vulkan type dependency, and the adapter adds no direct +OpenGL call. No account, region, asset, benchmark, GPU submission, or timing was +used. + +## Code size + +| Stage 17 code | Size | +| --- | ---: | +| Profile correction and existing focused-test updates | 353 additions, 54 removals | +| Neutral manifest schema and canonical recipes | 834 lines | +| Manifest and reflection-checker tests | 488 lines | +| Manifest dump, reflection verifier, and hash writer | 899 lines | +| Opt-in CMake wiring | 106 additions, 1 removal | + +The schema is intentionally value-owned and backend-neutral. The diagnostic +tooling is larger because it compares named reflection rather than trusting a +module hash, but it remains outside the default build graph and is reused by +the next production artifact. + +## Verification + +- The default Linux Release viewer relinked with the Vulkan option off and + project warnings treated as errors. +- The default universal macOS ReleaseOS viewer built and packaged after the + final source overlay. Its four focused CTest runners passed, both architecture + slices were present, and bundle-wide linkage isolation passed. +- The draw-packet, render-contract, shader-manifest, and viewer-adapter suites + passed 7, 28, 5, and 7 cases respectively. +- A fresh opt-in build recompiled and SPIR-V-validated both diagnostic modules, + ran all seven reflection-checker cases, validated the real reflected interface, + and wrote hashes only after success. +- All 57 benchmark-harness Python tests passed. Settings and command-line XML + parsed successfully. +- Clang-format dry-run, `git diff --check`, neutral dependency scans, direct-GL + scans, default-target scans, and executable linkage checks passed. + +## Reanalysis + +The diagnostic shader identity is now explicit and the production target +families can no longer alias it. Production lookup remains intentionally empty, +so a registry would still have nothing truthful to resolve. Source inspection +shows a smaller dependent boundary first: compile and reflect a distinct +four-output Vulkan artifact for opaque non-rigged normal-plus-specular draws. + +Stage 18 should preserve the three-output diagnostic bytes, make the wrapper's +fragment declaration four elements only for the production permutation, and +compile production variant 5 with emissive plus sun-and-spot identity. The +opaque path does not sample shadows, so it should expose no invented shadow +descriptors. Both Modern HDR and Compatibility pipeline keys may resolve the +same Vulkan shader interface because target formats remain pipeline state. + +Stage 18 must extend exact manifest and reflection coverage for the fourth +output and retain build-local hashes. It must not create a Vulkan registry, +render pass, pipeline, command submission, target-format capability policy, +runtime backend route, pixel-parity claim, or benchmark. Those remain later +stages after the production artifact itself is proven. diff --git a/doc/performance/renderer-stage-18-decision.md b/doc/performance/renderer-stage-18-decision.md new file mode 100644 index 00000000000..80db2603e02 --- /dev/null +++ b/doc/performance/renderer-stage-18-decision.md @@ -0,0 +1,213 @@ +# Stage 18 production material shader artifact decision + +## Decision + +Accept one separate production Vulkan material permutation for the existing +non-rigged opaque normal-plus-specular draw. The production vertex and fragment +modules compile and validate for Vulkan 1.1, their exact interfaces are derived +from the production manifest, and both production target profiles resolve the +same four-output shader contract. + +This is the third committable slice of master Stage 2. It proves a production +shader artifact without creating a shader registry, native shader module, +pipeline, descriptor set, parameter buffer, render pass, command path, backend +selector, or performance result. + +## Production permutation boundary + +The two Vulkan wrappers retain their diagnostic defaults. A separate compiler +selector, `LL_VULKAN_MATERIAL_PRODUCTION=1`, activates these effective defines +in both stages: + +- `HAS_EMISSIVE=1` +- `HAS_SUN_SHADOW=1` +- `SUN_SHADOW=1` +- `SPOT_SHADOW=1` + +The selector also changes the fragment declaration from `frag_data[3]` to +`frag_data[4]`. Without the selector, the preprocessed diagnostic interface and +generated modules remain byte-identical to Stage 17 with the same compiler. +The production vertex module has its own artifact path even though its unused +additional defines produce the same bytes as the diagnostic vertex module. + +The shared material equations were not changed. In opaque alpha mode, +`getShadow()` returns `1.0` and never calls the directional-shadow sampler. +Consequently the production wrapper records the viewer's sun-and-spot variant +identity but does not include `shadowUtil.glsl` and reflects no shadow uniforms +or descriptors. Shadow resources remain a later requirement for a blended +material variant that actually samples them. + +## Fourth output semantics + +The production fragment module declares four `vec4` outputs. The selected +legacy material path continues to place emissive brightness in output 0 alpha +and explicitly writes zero to output 3. Location 3 is nevertheless part of the +production pipeline interface, so the manifest names it `EmissiveBuffer` rather +than treating it as diagnostic baggage. + +The shader interface is independent of attachment formats. Modern HDR and +Compatibility retain their distinct ordered target formats in their pipeline +keys while resolving equal owned Vulkan shader manifests: + +| Target profile | Shader variant | Outputs | Location 3 target | +| --- | ---: | ---: | --- | +| Modern HDR | 5 | 4 | RGB16 float | +| Compatibility | 5 | 4 | RGB8 UNORM | + +Both production OpenGL lookups remain unresolved. The complete linked OpenGL +recipe is setting-dependent and was not inferred from the standalone Vulkan +artifact. + +## Exact production manifest + +The production manifest retains the diagnostic Vulkan wrapper and shared-source +order, seven dense vertex inputs, eight interstage variables, flat `vary_sign`, +272-byte parameter block at set 0 binding 0, and three fragment samplers at set +1 bindings 0 through 2. It changes only the program to encoded variant 5, adds +the production compiler selector and expanded effective defines, and adds the +fourth logical and declared output. + +Exact validation rejects reordered or missing defines, a diagnostic program +identity, source-order changes, an OpenGL backend label, changed descriptor +coordinates, three-output production descriptions, duplicate or unknown output +roles, output declaration changes, and mixed target-profile dimensions. The +generic validator still distinguishes structurally sound mutations from the +one canonical production recipe. + +The logical parameter list remains 68 words. For the currently admitted World +domain, `mirror_flag` remains the fixed value zero. Reflection, cube, HUD, +rigged, alpha-blended, and mirror-capable material paths are still rejected by +the dormant adapter. + +## Generated artifact and reflection evidence + +The existing opt-in build now exposes two independent targets: + +- `llvulkanmaterial_shaders` for the three-output diagnostic profile +- `llvulkanmaterialproduction_shaders` for the four-output production profile + +A small CMake profile helper gives each pair separate SPIR-V, reflection, +disassembly, manifest, hash, and completion-stamp paths. Each target compiles +and runs `spirv-val` before reflection, runs the checker suite and strict real +interface verification, writes its own hash file only after those gates pass, +and touches its own stamp last. + +With glslang 16.4, the clean build produced these build-local identities and +module sizes: + +| Profile and stage | SHA-256 | Bytes | +| --- | --- | ---: | +| Diagnostic vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | 8,068 | +| Diagnostic fragment | `e653afd5a5d541888cba40806c6b6f922db20ec2eb9414661893d8e29521f6ba` | 7,776 | +| Production vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | 8,068 | +| Production fragment | `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb` | 7,824 | + +Compiler versions are not pinned, so these hashes are evidence for this build +and are not source-controlled constants. Real `spirv-cross` reflection reported +a literal array size of 3 for diagnostic output and 4 for production output. +Applying the production expectation to the diagnostic reflection failed on the +missing location 3. + +## Hardened reflection gate + +The manifest dumper now requires an explicit `diagnostic` or `production` +profile and validates the selected canonical manifest before opening its output. +The same strict checker serves both profiles. + +Adversarial review found that reflected I/O array expansion initially ignored +`array_size_is_literal`. The checker now requires literal metadata to be a +same-length boolean array whose entries are all true. Missing, malformed, +length-mismatched, and non-literal metadata fail closed. The focused checker +suite now has 12 passing cases, including the diagnostic-to-production output +mismatch. A second full review found no remaining issue. + +## Default-build isolation + +All shader compilation and reflection wiring remains below the existing +default-off Vulkan diagnostic option. With the option restored to off, the +Linux build graph contains neither material Vulkan target nor the manifest +dumper. The Release viewer relinked without a Vulkan or MoltenVK dynamic +dependency. The normal material draw pools, OpenGL shader manager, bindings, +and submission path are unchanged, and the adapter still has no call site. + +A disposable macOS snapshot contained the Stage 17 commit plus exactly the nine +reviewed Stage 18 source changes. On macOS 26.6.2 with Xcode 26.6 and SDK 26.5, +the fresh ReleaseOS build used `arm64;x86_64`, tests enabled, renderer benchmark +instrumentation disabled, Vulkan diagnostics disabled, packaging enabled, and +signing and crash reporting disabled. All 166 build targets and the `llpackage` +target passed. The local package route assembled the universal app and base +package and wrote the standard universal-DMG package marker; it did not produce +a standalone DMG file. + +The viewer contained both architecture slices. The focused test executables +were native arm64. Direct viewer and focused-test linkage scans had no Vulkan or +MoltenVK hit. A bundle-wide scan read all 367 Mach-O files successfully and +found no Vulkan or MoltenVK dependency or named path. The ordinary Mac checkout +and installed viewer were not used or modified. The 32 GB disposable source, +dependency, build, package, and Python tree was removed after verification. + +No viewer, graphics context, device, account, region, asset, pixel comparison, +benchmark, or timing path ran on either platform. + +## Code size + +| Stage 18 code, excluding this decision record | Additions | Removals | +| --- | ---: | ---: | +| Profiled shader wrappers and CMake artifact helper | 149 | 126 | +| Production manifest schema and tests | 176 | 6 | +| Profile dumper, reflection checker, and checker tests | 152 | 28 | +| Total | 477 | 160 | + +The CMake removal count is principally the prior single-profile commands moved +into one profile helper. Production does not duplicate the verifier or shader +source. + +## Verification + +- Clean diagnostic and production shader targets compiled, passed Vulkan 1.1 + validation, reflected, disassembled, verified, hashed, and stamped + independently. +- Diagnostic module hashes match Stage 17 exactly. Production reflection has + four outputs and no additional descriptors. +- The draw-packet, render-contract, shader-manifest, and viewer-adapter suites + passed 7, 28, 7, and 7 cases respectively on Linux and macOS. +- All 12 reflection-checker cases and all 57 benchmark-harness Python tests + passed. Settings and command-line XML parsed successfully. +- Clang-format dry-run, Python parsing, `git diff --check`, credential and local + path scans, neutral dependency scans, direct graphics-API scans, default + target scans, and Linux and macOS linkage scans passed. +- Generated shader artifacts and review probes were removed. The Vulkan option + is off again, and the worktree contains only the Stage 18 change plus the two + pre-existing unrelated documents. + +## Explicit runtime gaps + +Stage 18 does not install or load SPIR-V, create a native shader module or +pipeline, choose target formats from device capabilities, allocate descriptors +or parameter storage, capture frame/pass state, resolve live viewer resources, +record commands, submit work, present pixels, compare parity, or measure +performance. A reflected shader is still not an executable production draw. + +## Reanalysis + +The production shader now has a truthful interface, but the owned Stage 16 draw +packet still cannot populate its 272-byte parameter block. It owns the draw +model and texture matrices plus specular, environment, alpha, and emissive +values. Modelview, modelview-projection, inverse-transpose normal, and clip-plane +values must be derived from explicit frame/pass state. The existing OpenGL path +currently obtains those values through mutable matrix and environment globals. + +The next smallest non-speculative boundary is therefore a pure production +parameter materializer. It should accept a valid production draw packet and a +value-owned World pass context with matching frame/pass identities, base +modelview, projection, and eye-space clip plane. It should return owned +`MaterialParameters`, derive matrices in legacy order, copy draw values, force +mirror to zero for the current World-only scope, and fail closed for diagnostic, +mismatched, nonfinite, singular, or overflow-derived inputs. + +A generation-aware shader registry should not precede this materializer. Stage +18 artifacts exist only in the opt-in build tree and are neither installed nor +loaded, so a registry today would publish injected test metadata without a real +producer or consumer. Native objects, artifact installation, registries, +resource pinning, pipeline creation, adapter call sites, and submission remain +later stages. diff --git a/doc/performance/renderer-stage-19-decision.md b/doc/performance/renderer-stage-19-decision.md new file mode 100644 index 00000000000..b0000d1efc2 --- /dev/null +++ b/doc/performance/renderer-stage-19-decision.md @@ -0,0 +1,186 @@ +# Stage 19 production material parameter decision + +## Decision + +Accept a pure backend-neutral materializer for the existing non-rigged opaque +World normal-plus-specular draw. A valid production draw packet and an explicit +matching frame/pass context now produce the complete owned 272-byte parameter +block required by the Stage 18 production Vulkan shader. + +This is the fourth committable slice of master Stage 2. It closes the parameter +ownership gap without installing or loading SPIR-V, publishing a shader, +creating a native object, changing a viewer call site, or executing a draw. + +## Public boundary + +`LegacyNormSpecWorldParameterContext` owns: + +- frame and pass identities; +- the base modelview matrix; +- the projection matrix; and +- the eye-space clip plane. + +`materializeLegacyNormSpecWorldParameters()` accepts only a validator-clean +Modern HDR or Compatibility production packet whose nonzero frame and pass +exactly match the context. Diagnostic packets, malformed packets, mismatched +identities, and nonfinite inputs fail closed. The function owns its result and +reads no renderer, matrix-stack, environment, OpenGL, or Vulkan global. + +The function maps the selected legacy path as follows: + +| Parameter | Source or derivation | +| --- | --- | +| Modelview | `base_modelview * draw_model` | +| Modelview-projection | `projection * modelview` | +| Normal | inverse-transpose of the modelview linear 3 by 3 block | +| Texture matrix 0 | draw diffuse texture matrix | +| Specular color | draw specular RGBA | +| Clip plane | context eye-space clip plane | +| Environment intensity | draw environment intensity | +| Emissive brightness | draw emissive value | +| Mirror flag | fixed zero for the admitted World domain | + +Alpha cutoff is deliberately absent. The admitted opaque shader variant does +not consume it. Modern HDR and Compatibility therefore return equal parameter +blocks for otherwise equal draws. + +Matrices use the viewer's contiguous column-major upload convention. The +composed modelview must be affine; projection remains free to be projective. +This matches the current World draw path and makes its boundary explicit. +Reflection, mirrors, rigging, alpha masking, alpha blending, and other material +domains remain rejected before this function. + +## Numerical fail-closed policy + +The normal matrix is calculated without GLM so `llrendercontract` remains an +independently linkable standard-library target. The implementation performs a +scaled double-precision inversion of the affine linear block, converts only +finite in-range values to the float parameter packet, and verifies both matrix +products after conversion. + +A normalized-row determinant gate rejects a numerically rank-deficient block. +The returned float inverse may have at most `1/1024` absolute two-sided +residual. Any singular 3 by 3 product differs from identity by at least `1/3` +in one entry, so this bound is separated from the singular boundary while +allowing normal float inversion error. + +Adversarial review found that the first float Gauss-Jordan version accepted an +exactly dependent matrix when cancellation left a small nonzero pivot. A +simple scaled-pivot threshold closed that bug but incorrectly coupled a row's +translation magnitude to its linear rank. A full-matrix residual likewise +rejected valid translated objects because inverse translation is rounded even +though the shader consumes only the normal block. The final affine linear-block +policy removes that unrelated state. + +The frozen implementation rejected both original counterexamples and 98,766 +additional exact row-dependent integer affine matrices with no false accept. +It accepted all 100,000 deterministic valid rotation, nonuniform-scale, and +translation fixtures with scales from 0.01 through 64 and translations through +plus or minus one million. Their maximum observed inverse residual was +`1.64552e-4`. A `1e-20` well-scaled axis and a one-ULP near-dependent but +invertible fixture also remained accepted. + +## Focused contract tests + +Eight deterministic cases cover: + +- identity derivation, every copied field, final validation, and all 68 word + offsets; +- noncommuting modelview and projection multiplication order; +- rotated nonuniform scale and inverse-transpose orientation; +- independent alpha-cutoff omission and production-profile equality; +- owned return values after every source object is mutated; +- diagnostic and malformed packet rejection; +- zero and mismatched frame/pass identities; +- nonfinite context, exact singularity, non-affine modelview, finite product + overflow, small scale, and large rotated translation. + +The approximate comparison helper rejects nonfinite operands. This prevents a +NaN from satisfying a tolerance assertion. + +## Build and backend isolation + +The new source is part of `llrendercontract`, and the focused integration test +links only that neutral library and `llcommon`. Source and linkage scans found +no OpenGL, Vulkan, MoltenVK, GLM, window, or viewer dependency in the contract. + +On Linux, the Release viewer and all six affected targets rebuilt with project +warnings treated as errors. The material-parameter, draw-packet, +material-diagnostic, render-contract, shader-manifest, and viewer-adapter +suites passed 8, 7, 4, 28, 7, and 7 cases. The focused neutral executables had +no graphics API linkage. With `LL_VULKAN_TONEMAP_TEST=OFF`, neither material +shader target nor the manifest dumper existed in the default graph, and the +viewer had no Vulkan or MoltenVK dependency. + +A disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK 26.5 contained +the Stage 18 commit plus exactly the four reviewed Stage 19 source files. Its +fresh ReleaseOS build used `arm64;x86_64`, tests enabled, packaging enabled, +renderer benchmark instrumentation disabled, Vulkan diagnostics disabled, and +signing and crash reporting disabled. All 167 build targets and the explicit +`llpackage` target passed. The package route assembled the universal app and +base package and wrote the standard universal-DMG marker; it did not leave a +standalone DMG file. + +The app executable contained both architecture slices. The six focused test +executables were native arm64, and all six runners passed. A post-package scan +read all 367 Mach-O files in the bundle and found no Vulkan or MoltenVK +dependency or named path. Source hashes on the Mac matched the frozen local +files. The ordinary Mac checkout and installed viewer were not used or +modified. The 32 GB disposable source, dependency, build, package, and Python +tree was removed after verification. + +All 12 reflection-checker cases and all 57 benchmark-harness Python tests also +passed. Settings and command-line XML parsed successfully. Clang-format, +whitespace, local-path, credential, backend-dependency, and default-graph +checks passed. + +No viewer, graphics context, GPU, account, region, world asset, pixel, +benchmark, or timing path ran on either platform. + +## Code size + +| Stage 19 code, excluding this decision record | Lines added | +| --- | ---: | +| Public contract and implementation | 288 | +| Focused integration tests | 356 | +| CMake wiring | 4 | +| Total | 648 | + +The inversion code is longer than a GLM call because this library deliberately +has no renderer math dependency and because singularity must fail closed rather +than propagate invalid shader values. + +## Explicit runtime gaps + +Stage 19 does not deliver or open a shader artifact, create a Vulkan shader +module or pipeline, choose attachment formats from a device, allocate parameter +or descriptor storage, capture live viewer state, resolve resources, publish a +generation, record or submit commands, present pixels, compare parity, or +measure performance. The inactive World clip plane must also be canonicalized +to a finite value at a later viewer capture boundary because the current +OpenGL global can contain an inert nonfinite mirror plane when mirror mode is +off. + +## Reanalysis + +The production shader and its complete parameter block now exist, but the +verified SPIR-V remains in an opt-in build directory. A registry still has no +truthful producer: no packaged bytes reach the viewer, and the neutral contract +has no shader publication generation or retirement lifecycle. + +The next smallest non-speculative stage is verified production artifact +delivery plus an owned-byte loader. The verified production vertex and +fragment modules should be copied explicitly to +`app_settings/shaders/vulkan/legacy_normspec`, with packaging depending on the +complete Stage 18 validation stamp. A Vulkan-free bounded loader should accept +an explicit app-settings root, own the SPIR-V words, validate the header and +instruction stream, require the expected `main` entry point and stage, and tie +the result to the canonical production manifest. + +That stage should remain opt-in and should deliver only the two production +modules. Diagnostic modules, reflection JSON, disassembly, hash evidence, +validation tools, Vulkan loaders, native objects, registries, viewer directory +globals, startup integration, backend selection, and GPU work remain later +stages. Once verified owned bytes exist at the runtime boundary, the following +stage can define generation-aware publication and retirement without inventing +test-only shader metadata. diff --git a/doc/performance/renderer-stage-2-decision.md b/doc/performance/renderer-stage-2-decision.md new file mode 100644 index 00000000000..91c31bd80e8 --- /dev/null +++ b/doc/performance/renderer-stage-2-decision.md @@ -0,0 +1,103 @@ +# Mac Stage 2 renderer benchmark decision + +## Decision + +The macOS benchmark display contract is ready for a controlled Apple Silicon baseline. Stage 2 fixes the rendered surface at 1280×720 backing pixels and the effective viewer UI scale at 1.0. It does not produce or preserve performance evidence. + +Stage 3 may collect the native OpenGL baseline only after the remaining scene-state gates are in place. Hardware comparison remains locked until Stage 4. + +## Display model + +Version 2 results keep `context.width` and `context.height` as backing pixels and add explicit fields for every coordinate space: + +| Field | Meaning on the verified 2× path | +| --- | ---: | +| Backing width and height | 1280×720 pixels | +| Cocoa logical width and height | 640×360 points | +| Backing scale X and Y | 2.0 | +| Configured `UIScaleFactor` | 0.5 | +| Effective display scale X and Y | 1.0 | + +On a 1× path, logical and backing dimensions are both 1280×720 and the configured UI factor is 1.0. The benchmark derives both cases from the factual native backing scale. It does not hardcode the Retina factor. + +Every version 2 scenario explicitly enables `RenderHiDPI`, requests a 1280×720 non-maximized window, and requests an effective scale of 1.0. The viewer applies the contract only in a benchmark-enabled build after the native window is attached. It reapplies the requested backing dimensions before reflowing the UI, which prevents Cocoa point dimensions from being saved as backing pixels. Ordinary builds compile this path out. + +## Validation behavior + +The runner rejects a result when: + +- legacy and explicit backing dimensions differ; +- actual backing dimensions differ from the manifest; +- logical dimensions, backing scale, and backing dimensions do not reconstruct the same surface; +- the configured UI factor does not produce the target scale; +- the final display scale differs from the target; +- any display field changes between comparable repeats; +- required display fields are missing; or +- a version 1 manifest or result is supplied. + +The LLLeap collector reapplies scenario settings after startup, requests the benchmark-only display normalization, and waits for the exported contract to settle before warmup. This keeps invalid geometry out of the capture window and still leaves final validation as a fail-closed boundary. + +## Stage 2 evidence + +- All 26 Python benchmark tests passed, including 1× and 2× result validation, geometry and scale mismatches, missing fields, old-schema rejection, and runtime normalization. The focused C++ helper test covers preserving the configured scale when benchmark normalization is inactive. +- The focused display and macOS directory integration tests passed. +- Benchmark-enabled universal ReleaseOS compilation and app packaging passed for Apple Silicon and Intel architectures. +- Benchmark-disabled universal compilation passed, confirming that normal viewer behavior remains on the existing path. +- A private native OpenGL smoke completed one unmeasured warm prime and one measured repeat on a Retina display. Both validated schema 2, 1280×720 backing pixels, 640×360 Cocoa points, 2.0 backing scale, 0.5 configured UI factor, and 1.0 effective display scale. +- Private visual inspection showed normalized UI coverage rather than the oversized coverage in the original diagnostic evidence. No image was retained. +- The smoke timings were discarded. Credentials, isolated viewer state, and live result artifacts were kept outside Git and removed after validation. + +Stage 2 changes no renderer backend, shader, normal profile, production service, signing identity, Vulkan dependency, Zink path, or performance conclusion. + +## Reanalysis + +`WindowWidth` and `WindowHeight` remain the requested backing workload in benchmark manifests. Cocoa content size is exported separately and no longer inferred from those settings. The runner can distinguish backing pixels, Cocoa points, native backing scale, configured UI scale, and final viewer scale without exporting a screen identifier or window position. + +The scale factor is derived only in a benchmark-enabled viewer with a positive benchmark target. A benchmark-disabled build and a zero target retain the current configured value. Version 1 data cannot satisfy the new contract because both schemas now require version 2 and the new fields. + +The remaining uncertainty is scene state, not display state. Asset completion, fixed camera, stable visible population, simulator events, focus, power, and thermal state still need an auditable gate before frame times become evidence. + +## Superseded Mac Stage 3 boundary + +### Objective + +This decision originally combined scene-validity implementation and a controlled Apple Silicon baseline in Mac Stage 3. The later Stage 4 reanalysis splits those tasks so every result is collected from a clean build of an already committed validity definition. + +### Scene-validity contract + +The first new commit adds only the scene-state facts needed to reject an unstable steady capture: + +- a stable workload identifier supplied without a private location; +- fixed camera and avatar movement state; +- visible avatar and active-object counts sampled before and after capture; +- asset-loading or pending-fetch state at the capture boundary; +- window focus state; +- simulator ping and material scene-event notes; and +- an operator record for power mode and thermal throttling. + +Prefer existing viewer facts and runner-side equality checks. Do not add a general telemetry system, screenshot pipeline, renderer abstraction, or platform comparison framework. + +### Deferred capture protocol + +The following work moves to Renderer Stage 5 and remains dependent on the committed schema-3 contract: + +1. Use the checked-in steady warm scenario without editing its settings or validity policy. +2. Use a controlled destination and fixed camera supplied outside Git. +3. Run native Apple OpenGL only with the expected Apple GPU. +4. Prime the isolated warm state once without retaining its timing. +5. For each measured repeat, warm up for 30 seconds and capture for 120 seconds. +6. Collect five valid repeats with identical source, settings, display fields, scene-state fields, build type, and instrumentation mode. +7. Discard and repeat any run that fails a display, scene, focus, asset, simulator-event, power, or thermal gate. +8. Keep raw live results and any visual reference private. Publish only a sanitized aggregate and decision record. + +### Exit evidence + +The baseline stage is complete only when five repeats pass every gate, run-to-run p95 range is reported, major CPU phase and resource-counter behavior is summarized, and no timing claim exceeds the existing greater-than-1-ms and greater-than-three-times-noise threshold without matching evidence. + +The baseline decision must choose one of these outcomes: + +- identify a bounded current-OpenGL investigation target supported by the controlled data; +- record that the baseline is stable but does not isolate a renderer bottleneck, then specify the next diagnostic trace; or +- keep the baseline blocked because the scene cannot yet be controlled. + +The matched hardware-class comparison remains locked until the baseline is complete. The detailed current boundary is in `renderer-stage-4-decision.md`. diff --git a/doc/performance/renderer-stage-20-decision.md b/doc/performance/renderer-stage-20-decision.md new file mode 100644 index 00000000000..aeeea2957fd --- /dev/null +++ b/doc/performance/renderer-stage-20-decision.md @@ -0,0 +1,225 @@ +# Stage 20 production material artifact decision + +## Decision + +Accept opt-in delivery and bounded, owned loading of the verified Stage 18 +production material shader pair. Packaging and installation now publish exactly +two canonical files: + +- `app_settings/shaders/vulkan/legacy_normspec/production.vert.spv` +- `app_settings/shaders/vulkan/legacy_normspec/production.frag.spv` + +The files come directly from the production shader target and are downstream of +its complete compile, `spirv-val`, reflection, and contract-verifier chain. +Build-local hashes and the final stamp are generated only after those gates. A +backend-neutral loader can read the installed pair into owned 32-bit words and +associate it with the canonical Modern HDR production manifest. + +This is the fifth committable slice of master Stage 2. It creates no Vulkan +object, pipeline, registry, viewer call site, graphics context, or GPU work. +The artifacts and loader/test targets remain behind +`LL_VULKAN_TONEMAP_TEST`; option-off manifest and install wiring only cleans +the dedicated destination. + +## Delivery boundary + +The manifest helper first removes only its dedicated destination. With the +option enabled it then copies exactly: + +| Validated build output | Installed or packaged name | +| --- | --- | +| `material.production.vert.spv` | `production.vert.spv` | +| `material.production.frag.spv` | `production.frag.spv` | + +It snapshots the source bytes, rejects symlinked source roots, source files, +destination roots, and destination parents, and safely unlinks a symlink at the +final destination. It checks that each manifest copy happens exactly once, +compares the delivered bytes, and rejects any extra destination entry. A +failure removes the partial destination. With the option disabled, the same +route removes stale files and delivers nothing. + +Linux copy and tar routes and the macOS all/explicit refresh and package routes +depend on the exact two SPIR-V outputs, the final production verification +stamp, the full production validation target, and an explicit ON/OFF state +file. A shader change or option transition therefore invalidates those delivery +routes instead of leaving an old staging tree or package. A direct already +up-to-date macOS viewer-only target does not imply the separate refresh target. + +Installation uses the same exact destination and removes it before installing +or when the option is disabled. `cmake --install` consumes existing outputs and +does not build them; the normal build `install` target reaches the production +validation target through the viewer dependency first. + +The cleanup code refuses to recurse through a symlinked parent and unlinks a +symlink at the final destination as a file. Its recursive scope is limited to +the dedicated `legacy_normspec` directory. + +## Loader contract + +`loadLegacyNormSpecProductionArtifacts()` accepts an explicit app-settings +root and returns either one typed error or one complete owned program. The +error alternative comes first, so default construction fails closed. Partial +programs are never returned. + +The loader derives program identity, stages, and entry-point names from the +canonical production manifest. For each module it requires a real nonsymlink +file, a word-aligned size from 5 words through 16 MiB, a complete binary read, +the little-endian SPIR-V magic value, a bounded instruction walk, exactly one +well-formed `OpEntryPoint`, the exact `main` name, and the expected vertex or +fragment execution model. It then moves the words into the result, so their +lifetime does not depend on the source files. + +These runtime checks detect truncation, basic instruction-boundary errors, and +obvious stage or entry-point mislabeling. They do not prove module hashes, +content identity, a complete SPIR-V interface, or semantic validity, and are +deliberately not a replacement for build-time `spirv-val` and reflection. The +app-settings tree is trusted installation data and must remain stable while a +load is in progress. Separate filesystem status and stream operations cannot +make this portable `std::filesystem` loader a sandbox against concurrent +hostile replacement. + +## Focused tests and review + +Eleven C++ cases cover fail-closed construction, manifest identity, canonical +success, missing roots and modules, no partial result, root, intermediate, and +leaf symlinks, nonregular files, undersized, misaligned, and oversized modules, +invalid magic, malformed instruction lengths, missing, renamed, unterminated, +padded, duplicate, and extra entry points, swapped execution models, and +ownership after source mutation and deletion. A deterministic portable +read-failure fixture was not added because it would require an injected +filesystem or stream seam solely for the test. + +Eight Python cases cover the exact pair and bytes, exclusion of intermediate +evidence, missing and symlinked sources, stale cleanup, option-off cleanup, +partial-copy rollback, and final and parent destination symlink sentinels. A +parse-level test also exercises the exact viewer-manifest command-line option. + +Adversarial review found and closed four material delivery issues before the +stage was frozen: the original command-line spelling did not match the +underscore argument parser, target-only dependencies did not express artifact +freshness to all generators, a failed second copy could leave a partial tree, +and destination parents needed explicit symlink rejection. Loader review also +prompted the fail-closed result ordering, intermediate-symlink and entry-point +padding cases, and the explicit filesystem trust boundary above. + +## Platform evidence + +On Linux, the production validation target rebuilt from clean shader outputs. +All 12 reflection cases and the final verifier passed; the build-local module +hashes and final stamp were then generated. The material-parameter, draw-packet, +material-diagnostic, +render-contract, shader-manifest, viewer-adapter, and artifact-loader suites +passed 8, 7, 4, 28, 7, 7, and 11 cases. All eight artifact-delivery cases and +the manifest argument test passed. The 57 benchmark-harness tests also passed, +but no benchmark run or timing was performed. + +A fresh `DESTDIR` install was seeded with stale nested files. Installation +replaced them with exactly the canonical pair. The installed and build-output +files compared byte for byte and had these SHA-256 values: + +| Module | SHA-256 | +| --- | --- | +| Vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | +| Fragment | `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb` | + +A disposable C++ probe loaded that installed root through the public loader +and reported program `deferred.material.normspec`, variant 5, 2,017 vertex +words, and 1,956 fragment words. The probe and install tree were then removed. +Reconfiguring the same build with the option disabled and reinstalling removed +the dedicated artifact directory. + +The real `LLManifest` copy route also staged the exact pair into a disposable +tree. The same public C++ loader read that staged app-settings root and reported +the same program, variant, and 2,017/1,956 word counts. An option-off pass then +removed the directory. The stock Linux tar target was not a clean platform gate +in this Nix environment: its pre-existing media-plugin target assumptions, +GStreamer include setup, and CMake 4 shared-library deploy logic fail before +this artifact route completes. Those unrelated packaging problems were not +folded into this stage. The exact manifest helper, generated dependency graph, +and full install route were exercised instead. + +The install gate initially exposed a stale reference to +`featuretable_solaris.txt`, which was removed from the repository long ago. +Removing that nonexistent file from `ViewerInstall.cmake` is the minimal +prerequisite repair included in this stage. + +A disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK 26.5 contained +the Stage 19 commit plus exactly the ten reviewed Stage 20 implementation +files. Its ReleaseOS build used `arm64;x86_64`, tests, the opt-in shader path, +and packaging, with renderer benchmarks, signing, and crash reporting disabled. +The production shader, loader integration, viewer, and `llpackage` targets all +passed with warnings treated as errors. Packaging wrote the standard universal +DMG marker but no standalone DMG file. + +The app bundle contained exactly the canonical vertex and fragment paths. Each +file compared byte for byte with its validated build output and matched the +Linux SHA-256 values above. A disposable neutral probe loaded the actual bundle +app-settings root, confirmed the exact stages and `main` entry points, and owned +2,017 vertex and 1,956 fragment words. It linked only the C++ and system runtime. +The loader and app contained both architecture slices; runnable test binaries +were native arm64 by existing project policy. + +The 11 loader, 8 delivery, manifest argument, 12 reflection, and six canonical +suites all passed on the Mac. All 367 Mach-O files in the bundle were readable +and had no Vulkan or MoltenVK linkage. After a full option-off reconfigure, the +loader and production targets and opt-in tests were absent, the viewer, refresh, +and package targets passed, and no dedicated SPIR-V or intermediate file +remained in the app. + +On both platforms with `LL_VULKAN_TONEMAP_TEST=OFF`, neither the artifact library +and test nor the production shader target exists in the build graph. The +Release viewer has no Vulkan or MoltenVK dynamic dependency. The Linux loader +integration test itself has no Vulkan, MoltenVK, OpenGL, X11, Wayland, or viewer +linkage. + +Clang-format, whitespace, settings and command-line XML parsing, credential, +local-path, backend-dependency, and private-artifact scans passed. Generated +modules and task-specific disposable probes, staging and install roots, and +private logs are removed before the commit. The disposable Mac source, build, +package, dependency, and probe root was also removed and verified absent. + +No viewer, graphics context, GPU, account, region, world asset, pixel, +benchmark, or timing path ran as part of this stage. + +## Code size + +| Stage 20 code, excluding this decision record | Lines added | +| --- | ---: | +| Owned loader contract and implementation | 442 | +| C++ loader integration tests | 465 | +| Delivery, cleanup, and Python tests | 359 | +| Build, manifest, and install wiring | 149 | +| Total | 1,415 | + +Five obsolete or replaced wiring lines were removed. The bounded parser and +defensive delivery code account for most of the size; no graphics backend code +was added. + +## Explicit runtime gaps + +Stage 20 does not publish a shader generation, retain an old generation for an +in-flight frame, retire a generation after completion, create a native shader +module or pipeline, select device formats or features, allocate parameters or +descriptors, capture viewer state, resolve resources, record or submit +commands, present pixels, compare parity, or measure performance. Nothing calls +the loader from the viewer yet. + +## Reanalysis + +Verified production bytes now cross the build-to-runtime boundary with an +owned representation, but there is still no lifecycle between those bytes and +future native resources. Creating a Vulkan shader module next would make +replacement and in-flight ownership implicit in backend code. + +The next smallest dependent stage is therefore neutral generation-aware shader +publication and retirement. It should publish only a fully loaded immutable +program, issue a fresh nonzero generation for replacement, resolve only exact +generation handles, retain superseded data while a recorded frame can still +refer to it, and retire it only after an explicit completion boundary. Unknown, +stale, wrapped, or already retired generations must fail closed. + +That stage should reuse existing handle and frame-completion conventions where +they fit and remain independently testable without Vulkan, a viewer global, or +a loader call site. Native shader modules, pipeline layout and cache policy, +descriptor allocation, backend selection, startup integration, and GPU work +remain later stages. diff --git a/doc/performance/renderer-stage-21-decision.md b/doc/performance/renderer-stage-21-decision.md new file mode 100644 index 00000000000..4841d73c6cc --- /dev/null +++ b/doc/performance/renderer-stage-21-decision.md @@ -0,0 +1,200 @@ +# Stage 21 shader publication lifecycle decision + +## Decision + +Accept one API-neutral publication owner for the canonical Stage 20 production +material program. A successful publication deep-copies the bounded loaded +program into immutable shared ownership and returns a typed `ShaderHandle`. +Replacement advances the generation exactly once, makes the old handle stale +for new resolution, and retains the old bytes until every frame recorded +against that generation has completed. + +This is the sixth committable slice of master Stage 2. The publication library, +test, and artifact admission path remain behind `LL_VULKAN_TONEMAP_TEST`. The +neutral handle declarations and manifest-validator safety repair remain in the +always-built contract. The stage creates no Vulkan object, graphics pipeline, +descriptor, frame resource, viewer call site, graphics context, or GPU work. + +## Handle and owner boundary + +`ShaderHandle` uses the neutral typed-handle representation but is deliberately +not a `ResourceHandle`. A loaded shader program is not yet a native frame +resource. + +The one `LegacyNormSpecShaderPublication` owner uses logical index 1. Its first +publication is `{1,1}`; every replacement preserves the index and advances by +exactly one nonzero generation. The generic generation helper rejects invalid +handles and refuses to wrap the maximum generation. The maximum value itself +is reachable from its predecessor and is terminal. + +Handles are owner-relative, not process-global identifiers. Two independent +owners can issue the same numeric handle, so a handle must never cross owner +instances. Copy and move construction and assignment are disabled to prevent +cloning or moving an existing owner identity. The owner is externally +sequenced and makes no thread-safety claim. It must outlive its handles and +in-flight frame tracking; shutdown must otherwise quiesce them externally +before destroying the owner. Its default destructor emits no retirement +records. + +## Admission and immutable ownership + +The artifact loader and publisher share +`validLegacyNormSpecProductionShaderProgram()`. It requires the exact canonical +production program identity and variant, vertex and fragment stage labels, +exact entry point labels, the expected SPIR-V execution models, a bounded +instruction walk, and the same 16 MiB per-module ceiling applied by the file +loader. Public aggregate construction therefore cannot bypass the loader's +memory bound. + +These are bounded runtime structural checks. They do not prove packaged-byte +provenance, hashes, full SPIR-V validity, reflected interfaces, or shader +semantics, and they are not substitutes for the build-time `spirv-val`, +reflection, and production-verifier chain. + +Each accepted value is copied into a new +`shared_ptr`. This is a deep copy even when the +caller passes an rvalue whose vectors still have outside aliases. Later caller +mutation or destruction cannot change the published words. + +Publication is transactional. An invalid initial program leaves the owner +empty. An invalid replacement, terminal generation, or allocation failure +leaves the current generation and pending generations unchanged. The +allocation-using manifest helpers no longer carry an unsound internal +`noexcept`; the public fail-closed validators catch allocation and canonical +construction failures and return `false`. + +## Frame leases and completion + +`resolveForFrame()` accepts only the exact current handle, a nonzero frame +strictly newer than the completion watermark, and globally nondecreasing record +order. Same-frame reacquisition is allowed. Every recorded frame must acquire +its own lease because reusing an older lease for a later frame would bypass +last-use tracking. + +Replacement makes the old handle logically stale immediately. The owner keeps +the superseded program and its maximum recorded frame in publication order. +The old and replacement generations may both acquire leases in the same frame; +both remain owned through completion of that frame. + +`completeThrough()` rejects zero and watermark regression, accepts equal +watermarks, and reports superseded generations in publication order only when +their last recorded frame is complete. It never retires the current generation. +Logical retirement removes owner reachability but does not revoke an outside +lease; the immutable bytes remain alive until the final shared owner releases +them. Missing completion intentionally grows pending ownership. A later native +completion service must advance the watermark. + +## Focused tests and review + +Nine publication cases cover: + +- typed-handle separation, exact generation arithmetic, terminal maximum, and + noncopyable and nonmovable owner identity; +- empty and canonical lookup, first publication, deep ownership from an + aliased rvalue, and wrong-key rejection; +- canonical structural acceptance plus program, variant, stage, label, + execution-model, word, and in-memory-size mutations; +- zero, unknown, old, future, completed, and regressing frame resolution; +- replacement, exact next generation, cross-generation record order, + same-frame mixed generations, immediate staleness, and caller mutation; +- partial and exact completion, ordered multigeneration retirement, current + retention, equal completion, and regression atomicity; and +- an outside lease surviving logical retirement and releasing physical bytes + only after its final reset. + +Adversarial review found and closed duplicate and moved-from owner identities, +an rvalue vector-alias hole, allocation-triggered termination in public +fail-closed manifest validation, unsafe test setup dereferences, missing +symmetric validator mutations, cross-generation frame regression coverage, +zero-completion coverage, masked transitive CMake dependencies, and misleading +fixture wording. No high- or medium-severity issue remains. + +Generation exhaustion through the real owner would require billions of +replacements, so only the reusable arithmetic helper is exercised at the +boundary. Allocation rollback is established by operation ordering and +nothrow commit steps but is not directly fault-injected. Same-frame mixed +generations and owner-relative handles are deliberate documented limits. + +## Linux evidence + +The Release viewer, publication library and integration test, and all affected +targets rebuilt with project warnings treated as errors. The publication, +artifact-loader, shader-manifest, render-contract, material-parameter, +draw-packet, material-diagnostic, and viewer-adapter suites passed 9, 11, 7, +28, 8, 7, 4, and 7 cases. All 12 reflection cases, all eight delivery cases, +the manifest argument test, and all 57 benchmark-harness tests also passed. No +benchmark scenario or timing ran. + +The current Nix command environment initially lacked the existing viewer's GLX +development headers. Supplying the immutable `libglvnd`, `libx11`, and +`xorgproto` include closures allowed the unchanged full viewer build to pass; +no source workaround was added. + +The publication integration executable has no Vulkan, MoltenVK, OpenGL, X11, +Wayland, SDL, window, or viewer dependency, and the publication source and +archive contain no native Vulkan API reference. With the option disabled, the +publication, artifact-loader, and production-shader targets are absent. The +option-off Release viewer remains free of Vulkan and MoltenVK linkage. Turning +the option back on restores the exact publication targets and test. + +Settings and command-line XML parsing, clang-format, whitespace, privacy, +local-path, backend-source, target-graph, and dynamic-linkage scans passed. + +## macOS evidence + +A disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK 26.5 contained +the Stage 20 commit plus exactly the eight frozen Stage 21 implementation +files. Their SHA-256 values matched before and after the gate. The ReleaseOS +build used `arm64;x86_64`, deployment target 11, tests, packaging, and the +opt-in shader path, with renderer benchmarks, signing, and crash reporting +disabled. All 13 requested sequential build invocations, including the viewer +and `llpackage`, passed with warnings treated as errors. + +The publication, artifact-loader, shader-manifest, render-contract, +material-parameter, draw-packet, material-diagnostic, and viewer-adapter suites +passed 9, 11, 7, 28, 8, 7, 4, and 7 cases. The 12 reflection cases, eight +delivery cases, and manifest argument parse also passed. The publication and +artifact archives contained both architecture slices; runnable integration +tests were native arm64 by existing project policy. + +The packaged app contained exactly the two canonical production modules, with +no intermediate evidence, and both were byte-identical to the validated build +outputs. The app and all 367 bundle Mach-O files had no Vulkan or MoltenVK +linkage. The publication executable, link line, source, and archives had no +native graphics API or higher-layer dependency. + +A full option-off reconfigure removed the publication, artifact, production, +and opt-in test targets. The universal viewer, cleanup-copy route, and package +passed. The final app had no dedicated material directory, bundled SPIR-V, or +intermediate evidence, and all 367 Mach-O files remained free of Vulkan and +MoltenVK linkage. Nine prior ON outputs remained unreachable in the build-only +shader directory after the in-place reconfigure; no OFF graph or package +referenced them, and deletion of the exact disposable root removed them. + +The Mac gate did not launch the viewer or touch a graphics context, GPU, +account, world, benchmark, or timing path. The exact disposable source, build, +dependency, package, log, and evidence root was deleted and verified absent. + +## Code size + +| Stage 21 code, excluding this decision record | Lines added | +| --- | ---: | +| Typed handle, shared admission, and fail-closed validation | 79 | +| Publication owner and opt-in build wiring | 229 | +| Publication integration tests | 312 | +| Total | 620 | + +Nine obsolete or unsound declaration and implementation lines were removed. + +## Explicit runtime gaps + +Stage 21 does not load the program from a viewer call site, create or destroy a +native shader module, own a Vulkan device or dispatch table, handle device loss, +define a pipeline layout or cache, allocate descriptors or parameter buffers, +bind frame resources, connect completion to a fence or timeline, record or +submit commands, present pixels, compare parity, or measure performance. + +The next-stage choice remains intentionally open until this stage is committed. +Post-commit reanalysis must decide whether native shader-module creation is now +the smallest complete dependent slice or whether another prerequisite is still +missing. diff --git a/doc/performance/renderer-stage-22-decision.md b/doc/performance/renderer-stage-22-decision.md new file mode 100644 index 00000000000..be210502022 --- /dev/null +++ b/doc/performance/renderer-stage-22-decision.md @@ -0,0 +1,212 @@ +# Stage 22 Vulkan material shader-module transaction decision + +## Decision + +Accept one opt-in, backend-private transaction that turns a canonical Stage 21 +shader-generation lease into an owned Vulkan vertex and fragment shader-module +pair. The factory receives a borrowed `VkDevice` and exactly +`PFN_vkCreateShaderModule` plus `PFN_vkDestroyShaderModule`. It creates no +loader, device service, pipeline, cache, descriptor, frame resource, viewer +call site, command, context, or GPU work. + +This is the seventh committable slice of master Stage 2. The library and its +integration test remain behind `LL_VULKAN_TONEMAP_TEST`. They use Vulkan types +from the headers but neither link the loader nor expose Vulkan handles through +the neutral renderer contract. + +## Borrowed native boundary + +`ShaderModuleDevice` copies one logical-device handle and the two exact +function pointers. The logical device must support Vulkan 1.1 or newer. The +device and implementation addressed by the pointers must remain valid until +every returned generation is destroyed, and callers externally synchronize +host access. + +`ShaderModuleGeneration` owns the logical `ShaderHandle`, the exact immutable +`LoadedShaderProgram` shared storage, and both native module handles. It is +noncopyable and nonmovable and transfers only through `unique_ptr`. It does not +retain the lease or its frame. Shader modules are transient pipeline-creation +inputs, so their lifetime is not a frame-completion or shader-publication +retirement policy. + +The error-first result distinguishes invalid device, incomplete dispatch, +invalid lease shape, owner-allocation failure, native create failure, and a +successful native result with a null handle. Native failures retain the exact +stage and `VkResult`. + +## Admission and provenance limits + +The factory rejects a null device, either missing function pointer, a null +program, zero frame, invalid handle, a handle outside the canonical logical +index, and any program rejected by +`validLegacyNormSpecProductionShaderProgram()`. All bounded validation and the +nothrow owner allocation finish before the first native callback. Once native +creation starts, result construction, rollback, and ownership transfer do not +allocate. + +The public lease is an aggregate and handles are relative to one publication +owner. The factory cannot prove that a numerically valid lease came from a +particular `LegacyNormSpecShaderPublication`, that its frame has not completed, +or that its bytes came through the trusted packaged-artifact route. Callers +must supply a lease from the matching live publication before that frame +completes, and its program must originate from the Stage 20 build-validated +artifacts. The runtime validator checks bounded structure and labels; it is not +`spirv-val`, hash-backed provenance, or semantic validation. + +## Transaction and destruction + +The owner and retained source bytes exist before the first callback. The +factory then creates vertex followed by fragment with an exact zero-initialized +`VkShaderModuleCreateInfo`, exact word pointer and byte count, null extension +chain, zero flags, and null allocation callbacks. + +Callback output remains local until the callback returns `VK_SUCCESS` with a +non-null module. A failed `VkResult` does not establish ownership of whatever +bits a callback may have written to the output. A failed vertex therefore +destroys nothing; a failed or null fragment destroys the already owned vertex. +Success owns both modules. Final destruction runs fragment then vertex through +the captured device and destroy pointer, again with null allocation callbacks. + +Review corrected two unsafe assumptions in the original Stage 22 draft: + +- a non-null value written alongside a failed `VkResult` is not a created + object and must not be destroyed; and +- non-dispatchable Vulkan handles are not guaranteed to have unique numeric + values. Two successful creates with equal `VkShaderModule` values still + create two independent destruction obligations, so the destructor calls + destroy twice in reverse creation order. + +## Focused tests and review + +Nine fake-dispatch cases cover: + +- result ordering, `noexcept`, and the owner's copy and move traits; +- null device and missing create or destroy dispatch with zero native calls; +- null program, invalid and noncanonical handles, zero frame, wrong program, + swapped stage, wrong entry point, and malformed words with zero native calls; +- exact device, create-info fields, retained source pointers, byte counts, + call order, logical handle, native handles, `unique_ptr` transfer, and + reverse destruction; +- source ownership after supported Stage 21 replacement and logical retirement; +- vertex and fragment native failures, including poisoned output values; +- success with a null vertex or fragment and exact rollback; +- equal numeric module handles with two successful creates and two destroys; + and +- two independent transactions for one valid lease, proving there is no + hidden cache or completion policy. + +The positive fixtures are minimal SPIR-V 1.3 vertex and fragment modules +compiled for Vulkan 1.1 and checked with `spirv-val`. They make fake call +formation internally coherent but do not establish production artifact +provenance or real-driver acceptance. + +Adversarial review found and closed an invalid duplicate-handle rule, unsafe +ownership of output written on native failure, an unsupported publication +owner-lifetime test, unchecked variant extraction, missing owner-relative and +completion preconditions, a vague allocation error, a missing Vulkan 1.1 +precondition, and an invalid positive SPIR-V fixture. No high- or +medium-severity issue remains in the reviewed transaction. + +Deterministic C++ owner-allocation failure is not fault-injected. The branch is +implemented with `new (std::nothrow)`, precedes every native callback, and has a +specific `OwnerAllocationFailure` result. Allocation failure inside the +bounded public validator remains fail-closed as `InvalidLease`, as established +by the validator contract. + +## Linux evidence + +The Release viewer, production shader validation target, new library and test, +and all affected integration targets rebuilt with project warnings treated as +errors. The module, publication, artifact-loader, shader-manifest, +render-contract, material-parameter, draw-packet, material-diagnostic, +viewer-adapter, and Vulkan material-registry suites passed. The 12 reflection +cases, eight delivery cases, manifest argument route, and all 57 benchmark +harness tests also passed. No benchmark scenario or timing ran. + +The new archive and integration executable have no direct +`vkCreateShaderModule` or `vkDestroyShaderModule` symbol. The executable has no +Vulkan, MoltenVK, OpenGL, X11, Wayland, window, or viewer dynamic dependency. +`VK_NO_PROTOTYPES` is private to the implementation and test so an accidental +direct loader call fails at compile time. + +With the option disabled, the module, publication, artifact-loader, and +production-shader targets are absent. The Release viewer remains free of +Vulkan and MoltenVK linkage. Restoring the option restores the exact targets. +Settings and command-line XML parsing, clang-format, whitespace, privacy, +local-path, source-boundary, target-graph, direct-symbol, and dynamic-linkage +checks passed. + +## macOS evidence + +A disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK 26.5 contained +the Stage 21 commit plus exactly the four reviewed Stage 22 implementation +files. Their SHA-256 values matched before and after the gate. The ReleaseOS +build used `arm64;x86_64`, deployment target 11, tests, packaging, and the +opt-in shader path, with renderer benchmarks, signing, and crash reporting +disabled. + +The module archive contains both architecture slices; the integration test is +native arm64 by existing project policy. It passed all nine fake-dispatch cases +with warnings treated as errors and valid ad-hoc signing. Its source, archive, +link line, and executable have no direct Vulkan create or destroy symbol and +no Vulkan, MoltenVK, OpenGL, or SDL dependency. The configured loader and +MoltenVK paths only satisfied the inherited opt-in configuration; the fake +test did not invoke either. + +The universal viewer, production validation target, exact artifact-copy route, +and package passed. The same 12 focused routes as Linux and all 12 reflection +cases passed. The benchmark harness passed 57 unit tests after the archive +snapshot received disposable empty Git metadata required by three dry-run +metadata cases. No benchmark was executed. + +The option-on app contained exactly the two canonical production modules, +byte-identical to the validated build outputs, with these SHA-256 values: + +| Module | SHA-256 | +| --- | --- | +| Vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | +| Fragment | `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb` | + +All 367 packaged Mach-O files were readable and had no Vulkan or MoltenVK +linkage. The scan used file descriptors for helper executables whose final +parenthesized names are otherwise interpreted by `otool` as archive-member +syntax. + +After a full option-off reconfigure, all opt-in targets were absent. The +universal viewer, cleanup-copy route, and package passed. The dedicated shader +directory and canonical production modules were absent, and all 367 packaged +Mach-O files remained free of Vulkan and MoltenVK linkage. Prior option-on +build products were unreachable in the build directory and were removed with +the exact disposable root. + +The Mac gate did not launch the viewer, set an ICD environment variable, touch +a graphics context, invoke a GPU, use an account or world asset, compare a +pixel, run a benchmark, or retain timing. The exact disposable source, build, +dependency, package, log, evidence, and Git-metadata root was deleted and +verified absent. + +## Code size + +| Stage 22 code, excluding this decision record | Lines added | +| --- | ---: | +| Backend header and implementation | 247 | +| Fake-dispatch integration tests | 453 | +| Opt-in build wiring | 24 | +| Total | 724 | + +No existing source line was removed. + +## Explicit runtime gaps + +Stage 22 does not create a real Vulkan logical device, obtain dispatch through +`vkGetDeviceProcAddr`, run `vkCreateShaderModule` against a driver, authenticate +artifact hashes at runtime, define a pipeline layout, create or cache a +pipeline, destroy pipeline generations after native completion, allocate +descriptors or staging memory, bind a frame resource, connect a viewer call +site, select a backend, record or submit commands, present pixels, compare +parity, or measure performance. + +The next-stage choice remains open until this stage is committed. Post-commit +reanalysis must decide whether the smallest dependent slice is a canonical +pipeline-layout transaction, a real device/dispatch owner, or another missing +pipeline prerequisite. diff --git a/doc/performance/renderer-stage-23-decision.md b/doc/performance/renderer-stage-23-decision.md new file mode 100644 index 00000000000..48e8cd6f5da --- /dev/null +++ b/doc/performance/renderer-stage-23-decision.md @@ -0,0 +1,209 @@ +# Stage 23 Vulkan material layout transaction decision + +## Decision + +Accept one opt-in, backend-private transaction that creates and owns the fixed +descriptor-set layouts and pipeline layout required by the canonical legacy +normal/specular material shaders. The factory receives a borrowed `VkDevice` +and exactly the create and destroy entry points for descriptor-set layouts and +pipeline layouts. It creates no loader, device service, descriptor pool or set, +shader module, graphics pipeline, cache, frame resource, viewer call site, +command, context, or GPU work. + +This is the eighth committable slice of master Stage 2. The library and its +integration test remain behind `LL_VULKAN_TONEMAP_TEST`. They use Vulkan types +from the headers but do not link the loader or expose Vulkan handles through +the neutral renderer contract. + +## Canonical interface + +The implementation has one compile-time layout recipe: + +- set 0, binding 0 is one uniform buffer visible to vertex and fragment + stages; +- set 1, bindings 0 through 2 are one combined image sampler each, visible + only to the fragment stage; +- the pipeline layout contains set 0 followed by set 1; and +- there are no immutable samplers, push constants, extension chains, or + create flags. + +This matches the production material manifest established by the earlier +neutral contract. The manifest is an independent test oracle, not a runtime +input to the Vulkan implementation. Stage 23 therefore cannot admit a +different interface through mutable data and does not add a second schema or +translation path. + +## Borrowed native boundary + +`MaterialLayoutDevice` copies one logical-device handle and four function +pointers: create and destroy descriptor-set layout, then create and destroy +pipeline layout. The logical device must support Vulkan 1.1 or newer. The +device and implementation addressed by the pointers must outlive every +returned owner, and callers externally synchronize host access. + +`LegacyNormSpecPipelineLayout` owns two descriptor-set-layout obligations and +one pipeline-layout obligation. It is noncopyable and nonmovable and transfers +only through `unique_ptr`. Its accessors return borrowed handles that expire +with the owner; the ordered descriptor layout pair is returned by value so a +caller cannot retain a reference into a destroyed owner. + +The layout interface is invariant across valid shader byte generations, so it +does not retain a Stage 21 publication lease or a Stage 22 shader-module +generation. Pipeline generations that eventually consume these layouts will +need a separate native-completion retirement policy. + +## Transaction and destruction + +The factory rejects a null device or any missing function pointer before a +native callback. The nothrow owner allocation also completes before the first +callback. The transaction then creates the parameter descriptor-set layout, +the sampled-image descriptor-set layout, and the pipeline layout in that +order, always with null allocation callbacks. + +Callback output remains local until the callback returns `VK_SUCCESS` with a +non-null handle. A failed `VkResult` does not establish ownership of output +bits written by the callback. A successful native result with a null handle +also fails closed. Once accepted, each successful create establishes one +destruction obligation even if two opaque non-dispatchable handles happen to +have equal numeric values. + +Failure rolls back only accepted objects in reverse creation order. Final +destruction is pipeline layout, sampled-image descriptor-set layout, then +parameter descriptor-set layout. Result errors distinguish invalid device, +incomplete dispatch, owner-allocation failure, native create failure, and +successful creation with a null handle. Native failures retain the exact +object and `VkResult`. + +## Focused tests and review + +Nine fake-dispatch cases cover: + +- result ordering, `noexcept`, and the owner's copy and move traits; +- null device and each missing entry point with zero native calls; +- exact device, create-info fields, binding types, counts, stages, layout + order, null allocation callbacks, and agreement with the production + manifest; +- failed and null parameter, sampled-image, and pipeline layout creation with + exact rollback; +- poisoned failed-call output, including a sampled-image output that aliases + the already owned parameter handle; +- equal values from two successful descriptor-layout creates, retaining two + independent destruction obligations; +- `unique_ptr` transfer and exact reverse final destruction; and +- two independent transactions whose opaque values deliberately repeat, + proving there is no hidden cache or owner coalescing. + +Adversarial review corrected a test that initially inferred owner independence +from unequal opaque handle values. Vulkan does not promise such uniqueness, so +the final test proves independence through distinct owners and exact callback +obligations while deliberately reusing all native values. Review also added +explicit borrowed-handle documentation and the poisoned-output alias case. No +high- or medium-severity issue remains in the reviewed transaction. + +Deterministic owner-allocation failure is not fault-injected. The branch is +implemented with `new (std::nothrow)`, precedes every native callback, and has +a specific `OwnerAllocationFailure` result. + +## Linux evidence + +The Release viewer, production shader validation chain, new library and test, +and all affected targets rebuilt with project warnings treated as errors. All +nine new fake-dispatch cases passed. Thirteen focused CTest routes covering the +draw-packet contract, material diagnostics and parameters, renderer contract, +shader manifest, artifact loader and delivery, publication, shader modules, +layouts, registry, manifest arguments, and viewer adapter passed. All 12 +reflection cases and all 57 benchmark-harness unit tests passed. No benchmark +scenario or timing ran. + +The new archive and integration executable have no direct +`vkCreateDescriptorSetLayout`, `vkDestroyDescriptorSetLayout`, +`vkCreatePipelineLayout`, or `vkDestroyPipelineLayout` symbol. The executable +has no Vulkan, MoltenVK, OpenGL, X11, Wayland, SDL, window, or viewer dynamic +dependency. `VK_NO_PROTOTYPES` is private to the implementation and test, so an +accidental direct loader call fails at compile time. + +With the option disabled, the new layout, module, publication, artifact, and +production-shader targets are absent. The Release viewer remains free of +Vulkan and MoltenVK linkage. Restoring the option restores the exact targets. +Settings and command-line XML parsing, clang-format, whitespace, privacy, +local-path, source-boundary, direct-symbol, and dynamic-linkage checks passed. + +## macOS evidence + +A disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK 26.5 contained +the Stage 22 commit plus exactly the four reviewed Stage 23 implementation +files. Their SHA-256 values matched before and after the gate. The ReleaseOS +build used `arm64;x86_64`, deployment target 11, tests, packaging, and the +opt-in shader path, with renderer benchmarks, signing, and crash reporting +disabled. + +The new archive contains both architecture slices; the integration test is +native arm64 by existing project policy. It passed all nine fake-dispatch cases +with warnings treated as errors and valid ad-hoc signing. Neither the archive +nor test contains a direct reference to the four Vulkan entry points, and the +test has no Vulkan, MoltenVK, OpenGL, or SDL dependency. The configured loader +and MoltenVK paths only satisfied the inherited opt-in configuration; fake +dispatch did not invoke either. + +The universal viewer, production validation chain, exact artifact-copy route, +and local package actions passed. The same 13 focused routes as Linux, all 12 +reflection cases, and all 57 benchmark-harness unit tests passed. The option-on +app contained exactly the two canonical production modules, byte-identical to +the validated build outputs, with these SHA-256 values: + +| Module | SHA-256 | +| --- | --- | +| Vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | +| Fragment | `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb` | + +All 367 packaged Mach-O files were readable and had no Vulkan or MoltenVK +linkage. The scan used file descriptors for helper executables whose final +parenthesized names are otherwise interpreted by `otool` as archive-member +syntax. + +After a full option-off reconfigure, every Vulkan implementation and shader +target was absent. The option-neutral artifact-copy target remained so the +manifest could remove stale outputs. The universal viewer and local +copy/package actions passed with an empty Vulkan artifact argument. The app +contained no SPIR-V files, and all 367 packaged Mach-O files remained readable +and free of Vulkan and MoltenVK linkage. + +The disposable gate required environment corrections before evidence was +accepted: project build flags had to be present, the manifest needed the +disposable Python environment containing `llsd`, the noninteractive path had +to expose CMake, and the regenerated Xcode option-off build needed its intended +macOS 11 target pinned explicitly. The final source builds passed; none of +these setup failures changed repository source or weakened a check. + +The Mac gate did not launch the viewer, set an ICD environment variable, touch +a graphics context, call the new transaction through a real driver, invoke a +GPU, use an account or world asset, compare a pixel, run a benchmark, or retain +timing. The exact disposable source, build, dependency, package, log, evidence, +and Git-metadata root was deleted and verified absent. + +## Code size + +| Stage 23 code, excluding this decision record | Lines added | +| --- | ---: | +| Backend header and implementation | 288 | +| Fake-dispatch integration tests | 529 | +| Opt-in build wiring | 22 | +| Total | 839 | + +No existing source line was removed. + +## Explicit runtime gaps + +Stage 23 does not create a real Vulkan logical device, obtain dispatch through +`vkGetDeviceProcAddr`, run any layout create through a driver, allocate a +descriptor pool or descriptor set, write descriptors, retain buffer, image, +or sampler resources, consume the Stage 22 shader modules, create or cache a +graphics pipeline, define render-pass compatibility or dynamic rendering, +retire native generations after GPU completion, connect a viewer call site, +select a backend, record or submit commands, present pixels, compare parity, or +measure performance. + +The next-stage choice remains open until this stage is committed. Post-commit +reanalysis must decide whether the smallest dependent slice is descriptor +allocation, graphics-pipeline prerequisites, real device dispatch, or another +missing resource-lifetime boundary. diff --git a/doc/performance/renderer-stage-24-decision.md b/doc/performance/renderer-stage-24-decision.md new file mode 100644 index 00000000000..8f22638827e --- /dev/null +++ b/doc/performance/renderer-stage-24-decision.md @@ -0,0 +1,216 @@ +# Stage 24 Vulkan material descriptor-generation decision + +## Decision + +Accept one opt-in, backend-private transaction that allocates and populates a +bounded immutable batch of descriptor-set pairs for the canonical legacy +normal/specular material interface. For N resource tuples, the factory creates +one exact descriptor pool, allocates 2N sets in canonical set-0/set-1 order, +writes all 4N bindings once, and publishes the generation only after the update +returns. + +This is the ninth committable slice of master Stage 2. The library and its +integration test remain behind `LL_VULKAN_TONEMAP_TEST`. They use injected +Vulkan functions and `VK_NO_PROTOTYPES`; they do not link the loader, expose +Vulkan handles through the neutral renderer contract, or define a frame, +pipeline, submission, or viewer policy. + +## Immutable resource contract + +Each tuple supplies one non-null uniform buffer, its declared creation size, +an explicit offset, and three non-null sampler/image-view pairs. The canonical +`MaterialParameters` block is 272 bytes. The factory verifies that the full +block fits at the offset and always writes an exact 272-byte uniform range. +Each sampled binding is written as one combined image sampler in +`VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL`. + +Raw handles cannot prove resource provenance or state. The caller must ensure +that every resource belongs to the supplied Vulkan 1.1-or-newer device, was +created for the declared use, and remains valid through the last submitted +command that consumes the generation. Uniform offsets must satisfy the +device's `minUniformBufferOffsetAlignment`. Images must be in shader-read-only +layout when consumed, and sampled resources must not use YCbCr conversion. + +The Stage 23 layout owner must have been created on the same device and must +conservatively outlive the descriptor generation. `createdOn()` supplies this +narrow provenance check without exposing mutable layout state. + +## Count and allocation boundary + +Input must contain at least one tuple and at most `UINT32_MAX / 4` tuples. The +upper bound makes the 2N set, 3N sampled-image, and 4N write calculations safe +in Vulkan's 32-bit count fields. Every input handle and uniform range is +validated before a native callback. + +The implementation allocates all host-side storage before pool creation: +copied binding metadata, alternating layout handles, set outputs, buffer infos, +image infos, write records, and the nothrow generation owner. Host allocation +failure therefore cannot strand a native pool. Deterministic allocation +failure is not fault-injected; the evidence is the explicit exception and +`new (std::nothrow)` handling plus source ordering before the first callback. + +The pool has zero flags, `maxSets = 2N`, exactly N uniform-buffer descriptors, +and exactly 3N combined-image-sampler descriptors. One allocation requests the +alternating canonical layout sequence `[set0, set1] x N`. There is no +individual-free or reset capability. + +## Native transaction and ownership + +Only `VK_SUCCESS` plus a non-null pool establishes pool ownership. Output bits +written alongside a failed result are ignored. Set allocation is one Vulkan +transaction; a failed result preserves its exact `VkResult`, ignores every +output slot, and rolls back the accepted pool. A successful allocation must +produce 2N non-null sets. Any null slot fails closed with its tuple and set +kind identified. + +After allocation, one void update call receives exactly four writes per tuple: +set 0 binding 0 receives the uniform buffer, offset, and fixed range; set 1 +bindings 0 through 2 receive the corresponding sampler and image view. No +binding pair can escape before every write has been issued. + +`LegacyNormSpecDescriptorGeneration` is noncopyable and nonmovable and +transfers only through `unique_ptr`. It owns the pool and copied borrowed +metadata, not the buffers, views, samplers, layouts, device, or dispatch. +Destroying the pool implicitly frees every set. The owner exposes no pool, +free, reset, or update operation; callers with the borrowed raw set handles +must preserve that immutability externally. A generation can be reused across +draws and frames while its resources remain valid; recreating it every frame +is not a policy of this stage. Callers must wait for all submitted users to +complete before destroying it, as required by the +[Vulkan descriptor-set lifetime rules](https://docs.vulkan.org/spec/latest/chapters/descriptorsets.html). + +Opaque non-dispatchable handle equality is never used as object identity. +Equal non-null parameter and sampled-set values are valid, and the existing +material registry now applies that rule at both exact consumers while still +rejecting either null set. This follows Vulkan's +[non-dispatchable handle model](https://docs.vulkan.org/spec/latest/chapters/fundamentals.html#fundamentals-objectmodel). + +## Focused tests and review + +Nine fake-dispatch cases cover: + +- result ordering, `noexcept`, owner traits, bounds checks, and out-of-range + access; +- null device, each missing dispatch entry, a layout/device mismatch, empty + and oversized counts, every invalid resource field, and later-tuple error + context, all before native callbacks; +- exact N=2 pool sizes, flags, alternating layouts, allocation call, 272-byte + buffer ranges including a nonzero offset, 4N writes, image layout, ordered + publication, and copied resource metadata; +- failed pool creation with poisoned output, successful creation with a null + pool, failed set allocation, and null parameter or sampled sets at multiple + positions, with exact rollback; +- equal non-null set and resource values, owner transfer, one final pool + destruction, and independent generations that deliberately reuse opaque + values; and +- direct registry proof that an equal non-null pair is accepted while either + null slot is rejected through the same rule used by both consumers. + +Two independent adversarial reviews found no remaining high-severity product +defect. Review replaced a false-positive registry assertion with the pure rule +used by the production paths, documented and exercised nonzero uniform +offsets, added precise context for a later invalid tuple, and removed a no-op +address assertion. Owner-allocation failure remains structurally reviewed, not +deterministically injected. + +## Linux evidence + +The Release viewer, production shader validation chain, descriptor archive and +test, and all affected targets rebuilt with project warnings treated as +errors. All nine new fake-dispatch cases passed. Fourteen focused CTest routes +covering draw packets, material diagnostics and parameters, renderer contract, +shader manifest, artifact loading and delivery, publication, shader modules, +layouts, descriptors, registry, manifest arguments, and the viewer adapter +passed. All 12 reflection cases and 65 Python unit tests passed: 57 benchmark +harness cases and eight artifact-delivery cases. No benchmark scenario or +timing ran. + +The descriptor archive and integration executable have no direct descriptor +pool, allocation, update, or destroy symbol. The executable has no Vulkan, +MoltenVK, OpenGL, window-system, SDL, or viewer dynamic dependency. With the +option disabled, every opt-in material implementation and production-shader +target is absent, the Release viewer still builds, and Vulkan or MoltenVK is +not linked. Restoring the option restores the descriptor test. Formatting, +whitespace, privacy, source-boundary, symbol, dynamic-linkage, and option +isolation checks passed. + +## macOS evidence + +A disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK 26.5 contained +the Stage 23 commit plus the nine reviewed Stage 24 source, test, and build +files. Their SHA-256 values matched the local working tree at the end of the +gate. The ReleaseOS build used `arm64;x86_64`, deployment target 11, tests, +packaging, and the opt-in shader path, with renderer benchmarks, signing, and +crash reporting disabled. + +The descriptor archive contains both architecture slices; the integration +test is native arm64 by existing project policy. It passed all nine +fake-dispatch cases with warnings treated as errors and valid ad-hoc signing. +Neither the archive nor test directly references descriptor-pool creation, +allocation, update, or destruction, and the test has no Vulkan, MoltenVK, +OpenGL, or SDL dependency. The configured loader and MoltenVK paths only +satisfied inherited opt-in configuration; fake dispatch invoked neither. + +The universal viewer, production validation chain, exact artifact-copy route, +and local package passed. All 14 focused routes, 12 reflection cases, 57 +benchmark-harness tests, and eight artifact-delivery tests passed. No benchmark +scenario ran. The option-on app contained exactly the two canonical production +modules, byte-identical to the validated build outputs: + +| Module | SHA-256 | +| --- | --- | +| Vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | +| Fragment | `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb` | + +All 367 packaged Mach-O files were readable and had no Vulkan or MoltenVK +linkage. The scan used file descriptors for helper executables whose +parenthesized names would otherwise be interpreted by `otool` as archive +members. + +After a full option-off reconfigure, every opt-in material implementation, +test, and production-shader target was absent. The option-neutral artifact-copy +target remained to remove stale outputs. A full universal warnings-as-errors +viewer rebuild and package passed, the app contained no SPIR-V files, and all +367 Mach-O files remained readable and free of Vulkan and MoltenVK linkage. + +The disposable gate needed several environment and orchestration corrections +before evidence was accepted: explicit `spirv-cross` and `spirv-dis` paths, +the expected `LL_BUILD` and deployment inputs, an empty local Git commit for +three metadata-dependent harness cases, explicit builds for focused test +executables omitted by the package target, and a linkage scan limited to +dependency lines rather than a test filename containing “vulkan.” A final +comment-only source sync rebuilt its affected target before tests. None of +these corrections changed runtime source, suppressed a test, or weakened an +acceptance condition. + +The Mac gate did not launch the viewer, set an ICD environment variable, touch +a graphics context, call the transaction through a driver, invoke a GPU, use +an account or world asset, compare a pixel, run a benchmark, or retain timing. +The exact disposable source, build, dependency, package, log, evidence, and +Git-metadata root was validated as user-owned, deleted, and verified absent. + +## Code size + +| Stage 24 code, excluding this decision record | Lines added | Lines removed | +| --- | ---: | ---: | +| Descriptor backend header and implementation | 441 | 0 | +| Fake-dispatch descriptor tests | 694 | 0 | +| Consumer and layout corrections | 28 | 3 | +| Opt-in build wiring | 24 | 0 | +| Total | 1,187 | 3 | + +## Explicit runtime gaps + +Stage 24 does not create a Vulkan instance, physical or logical device, obtain +dispatch through `vkGetDeviceProcAddr`, call a real driver, allocate buffers or +images, establish image transitions, enforce native completion, define a +portable attachment profile, choose render-pass compatibility or dynamic +rendering, create or cache a graphics pipeline, retain shader modules, connect +a viewer call site, select a backend, record or submit commands, present or +compare pixels, or measure performance. + +The next-stage choice remains open until this stage is committed. Post-commit +reanalysis must choose the smallest dependency among portable attachment +formats and alpha semantics, render-pass or dynamic-rendering policy, pipeline +ownership and caching, real device dispatch, and completion-backed descriptor +reuse. diff --git a/doc/performance/renderer-stage-25-decision.md b/doc/performance/renderer-stage-25-decision.md new file mode 100644 index 00000000000..92ed4ce6adf --- /dev/null +++ b/doc/performance/renderer-stage-25-decision.md @@ -0,0 +1,227 @@ +# Stage 25 Vulkan material attachment-profile decision + +## Decision + +Accept one opt-in, backend-private resolver that turns the canonical legacy +normal/specular pipeline key into an immutable, physical-device-specific +attachment profile. The profile fixes the native formats, image roles, +required capabilities, load and clear contract, color write masks, and alpha +semantics needed by a later render-pass and graphics-pipeline owner. Stage 27 +later strengthened this profile with the physical-device feature check and +logical-device requirement needed to make the unequal write masks valid. + +This is the tenth committable slice of master Stage 2. The library and its +integration test remain behind `LL_VULKAN_TONEMAP_TEST`. They use injected +physical-device query functions and `VK_NO_PROTOTYPES`; they allocate no native +objects, link no Vulkan loader, and expose nothing through the neutral renderer +contract. + +## Portable format decision + +The resolver accepts only the two canonical production target profiles. It +uses four color attachments and one depth attachment at one sample per pixel. + +| Profile | Slot | Logical format | Native Vulkan format | +| --- | ---: | --- | --- | +| Modern HDR | 0 | RGBA8 UNORM | `VK_FORMAT_R8G8B8A8_UNORM` | +| Modern HDR | 1 | RGBA8 UNORM | `VK_FORMAT_R8G8B8A8_UNORM` | +| Modern HDR | 2 | RGBA16 UNORM | `VK_FORMAT_R16G16B16A16_UNORM` | +| Modern HDR | 3 | RGB16 float | `VK_FORMAT_R16G16B16A16_SFLOAT` | +| Compatibility | 0 | RGBA8 UNORM | `VK_FORMAT_R8G8B8A8_UNORM` | +| Compatibility | 1 | RGBA8 UNORM | `VK_FORMAT_R8G8B8A8_UNORM` | +| Compatibility | 2 | RGB10A2 UNORM | `VK_FORMAT_A2B10G10R10_UNORM_PACK32` | +| Compatibility | 3 | RGB8 UNORM | `VK_FORMAT_R8G8B8A8_UNORM` | +| Both | depth | Depth24 UNORM | `VK_FORMAT_D32_SFLOAT` | + +Logical three-channel outputs widen to four-channel native images. The +resolver never requests a three-channel Vulkan image. This avoids depending on +optional three-channel attachment support while preserving the shader-visible +RGB contract. + +Every color role is both a color attachment and a sampled image. Depth is both +a depth/stencil attachment and a sampled image. Transfer usage is not claimed. +All roles use two-dimensional, optimal-tiling images with zero creation flags, +at least one mip level and array layer, nonzero width, height, and depth +capability, and sample-count-one support. + +## Alpha, load, and clear invariant + +Slots 0 through 2 write RGBA, load with `VK_ATTACHMENT_LOAD_OP_CLEAR`, and +clear to `(0, 0, 0, 0)`. Slot 3 represents a logical RGB output using an RGBA +image. It writes RGB only, loads with `VK_ATTACHMENT_LOAD_OP_CLEAR`, and clears +to `(0, 0, 0, 1)`. Its alpha is therefore explicitly one after the clear and +cannot be altered by the material fragment shader. + +The widened slot's `ImplicitOneAfterClear` semantic, RGB write mask, required +load operation, and clear value form one invariant. A later render-pass and +pipeline implementation must consume them together. Depth likewise requires a +clear load with depth 1 and stencil 0. + +The fourth color attachment state differs from the first three, even though +blending is disabled for every slot. Vulkan therefore requires the optional +`independentBlend` feature to be enabled on the logical device. Using an RGBA +write mask for slot 3 is not an alternative because the production fragment +shader writes zero to that alpha component. + +## Capability and provenance boundary + +Before resolving a profile, the resolver queries physical-device features once +and requires `independentBlend`. It then requires at least four color +attachments and four fragment outputs. Each ordered attachment slot is queried +for the exact optimal-tiling format features and image role that will be +published. Repeated native formats are intentionally queried per slot so a +failure retains exact slot and logical-format context; deduplicating these few +queries is deferred until measurement shows a need. + +The result copies the returned `VkImageFormatProperties` envelope and records +the physical device used for selection. Only the resolver can construct or +mutate the profile; it is neither an aggregate nor default-constructible. +`selectedFor()` provides the narrow provenance check needed by later owners. +The profile also retains an immutable typed device requirement whose +`independentBlendRequired()` accessor returns true. The successful feature +query proves support on the selected physical device. It does not prove that +an existing `VkDevice` enabled the feature. A future logical-device owner must +enable the retained requirement and provide its own authenticated capability +view to native pipeline creation. + +These capability records are maxima, not permission for an arbitrary +allocation. The future image and framebuffer owner must validate its concrete +extent, layers, mip count, sample count, and resource size before publishing +resources. The caller owns the physical device and guarantees that its four +query callbacks address the same Vulkan implementation for the duration of +the call. + +## Failure contract + +Invalid device, missing dispatch, malformed production key, and unsupported +diagnostic-profile inputs fail before a callback. Missing +`independentBlend` support fails after the sole feature query with exact query +and feature context, before properties or attachment queries. Limit failures +identify the exact physical-device limit and required versus available value. +Format and image-role failures identify the query, attachment kind, color slot +when applicable, logical and native formats, required and available feature +bits, native `VkResult`, or the exact missing capability and values. + +Resolution stops at the first failed ordered role. A Modern HDR failure does +not silently fall back to compatibility. The function is `noexcept`, owns no +native resource, and returns either one complete immutable profile or one +typed error. + +## Focused tests and review + +The original Stage 25 commit had eight fake-dispatch cases covering: + +- result type, `noexcept`, fixed attachment count, and the profile's + construction and immutability boundary; +- every invalid input before native callbacks; +- exact Modern HDR and compatibility mappings, ordered queried-to-published + formats, usage, tiling, flags, capability copies, provenance, clears, alpha + semantics, and write masks; +- both four-attachment physical limits with early termination; +- each required optimal-tiling feature missing from every ordered role, + including proof that linear or buffer support does not substitute; +- every ordered image-format query returning a native failure without + fallback; and +- each separate image capability dimension plus a depth failure with exact + typed context. + +Stage 27 adds a ninth case, supplies the feature callback to every existing +fixture, and extends the two success cases. The suite now checks missing +feature dispatch before every callback, unsupported physical support with exact +typed context, one early feature query, full callback order, retained logical +enablement requirements for both profiles, and zero later queries after +feature rejection. The Stage 26 render-pass fixture also checks that its owner +retains the strengthened profile unchanged. + +The Stage 25 reviews made the result non-aggregate and +non-default-constructible, clarified that its capabilities are not a concrete +allocation guarantee, coupled the widened RGB alpha semantic to its clear and +write mask, split width, height, and depth failures, and added direct proof that +every queried format is the one published. Stage 27 reanalysis found and closed +the missing optional-feature prerequisite. Repeated per-slot queries remain a +deliberate diagnostic tradeoff. + +## Linux evidence + +The Release viewer, production shader chain, attachment archive, integration +test, and affected targets built with project warnings treated as errors. All +eight new cases and 15 focused CTest routes passed. The related Python suites +passed all 12 reflection, eight artifact-delivery, and 57 benchmark-harness +tests. No benchmark scenario or timing ran. + +The attachment archive has no direct physical-device query symbol, and its +integration executable has no Vulkan, MoltenVK, OpenGL, SDL, window-system, or +viewer dynamic dependency. With the option disabled, the attachment and other +opt-in material targets were absent, the Release viewer built, and no Vulkan +or MoltenVK dependency or packaged shader artifact escaped. Restoring the +option rebuilt the attachment test and production shader chain. Formatting, +whitespace, source-boundary, symbol, dynamic-linkage, and option-isolation +checks passed. + +## macOS evidence + +A user-owned disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK 26.5 +contained the Stage 24 commit plus the four reviewed Stage 25 source, test, and +build files. Their SHA-256 values matched the final local working tree. The +ReleaseOS gate used `arm64;x86_64`, deployment target 11, tests, packaging, +warnings as errors, and the opt-in shader path, with benchmarks, signing, and +crash reporting disabled. + +The attachment archive contains both architecture slices; the integration +test is native arm64 under existing project policy. All eight new cases, all +15 focused CTest routes, 12 reflection cases, eight artifact-delivery cases, +and 57 benchmark-harness tests passed. The test has no graphics-runtime +dependency and the archive has no direct physical-device query symbol. The +configured loader and MoltenVK paths only satisfied inherited opt-in +configuration; fake dispatch called neither. + +The option-on app contained exactly the two canonical production modules: + +| Module | SHA-256 | +| --- | --- | +| Vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | +| Fragment | `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb` | + +All 367 packaged Mach-O files were readable and had no Vulkan or MoltenVK +linkage. After an option-off reconfigure, the attachment test, library, and +production-shader targets were absent. A fresh universal viewer build and +package passed, the app contained zero SPIR-V files, and the same 367 Mach-O +files remained free of Vulkan and MoltenVK linkage. + +The first direct option-off rebuild lost the disposable venv's explicit +`autobuild` path during reconfiguration and stopped in package metadata +generation. Restoring that tool path and the build-variable environment made +the complete gate pass without a source change or weakened check. The gate did +not launch the viewer, create a graphics context, call a driver, use an +account, enter a region, compare pixels, or collect timing. Its exact +disposable source, build, dependency, package, and metadata root was validated +as user-owned, deleted, and verified absent. + +## Code size + +| Stage 25 code, excluding this decision record | Lines added | Lines removed | +| --- | ---: | ---: | +| Attachment backend header and implementation | 417 | 0 | +| Fake-dispatch attachment tests | 487 | 0 | +| Opt-in build wiring | 24 | 0 | +| Total | 928 | 0 | + +## Explicit runtime gaps + +Stage 25 does not create a Vulkan instance, physical or logical device, query +dispatch through `vkGetInstanceProcAddr`, call a real driver, allocate memory +or images, validate a concrete extent or memory size, create an image view, +framebuffer, render pass, or graphics pipeline, establish a layout transition, +record or submit commands, synchronize completion, retain shader modules, +connect a viewer call site, select a backend, present or compare pixels, or +measure performance. + +Stage 27's injected feature callback does not change those runtime limits. It +proves physical support only in the supplied query transaction and publishes a +future enablement requirement. It neither creates nor authenticates a logical +device. + +The profile is descriptive and non-executable until later owners consume its +load, clear, format, usage, and write-mask contract. The next-stage choice +remains open until this stage is committed and the dependency graph is +reanalyzed. diff --git a/doc/performance/renderer-stage-26-decision.md b/doc/performance/renderer-stage-26-decision.md new file mode 100644 index 00000000000..8eebdfb2a0f --- /dev/null +++ b/doc/performance/renderer-stage-26-decision.md @@ -0,0 +1,202 @@ +# Stage 26 Vulkan material render-pass decision + +## Decision + +Accept one opt-in, backend-private owner for the classic Vulkan 1.1 render +pass compatible with the canonical legacy normal/specular attachment profile. +The factory consumes one immutable Stage 25 profile and returns either one +complete uniquely owned `VkRenderPass` generation or one typed error. + +This is the eleventh committable slice of master Stage 2. The library and its +integration test remain behind `LL_VULKAN_TONEMAP_TEST`, use injected dispatch +with `VK_NO_PROTOTYPES`, and expose nothing through the neutral renderer +contract. The object describes one shared deferred pass generation. It does +not begin a render pass and must not be created or cleared per material draw. + +## Render-pass compatibility policy + +The create info contains exactly five ordered attachment descriptions: + +1. the four color formats from the retained Stage 25 profile; then +2. the profile's depth format. + +Every attachment uses flags zero and one sample. Color and depth consume the +profile's required `CLEAR` load operation and use `STORE`; stencil load and +store are `DONT_CARE`. One graphics subpass references colors 0 through 3 and +depth 4. It has no input, resolve, preserve, multiview, or extension state. +The create info has zero explicit dependencies. + +Color initial, subpass, and final layouts are all +`VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL`. Depth initial, subpass, and final +layouts are all `VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL`. Keeping +each attachment in one pass-local layout avoids an incomplete hybrid of +automatic transitions and external synchronization. The owner exposes both +layout values so later code does not duplicate private policy. + +Attachment flags zero mean this pass does not permit overlapping attachment +memory. A future concrete resource owner must preserve that non-aliasing +policy unless the render-pass compatibility contract is deliberately changed. + +## Synchronization and clear boundary + +The render pass performs no automatic layout transition. A future image-aware +command encoder must transition actual images into the exposed attachment +layouts before begin and out to their real consumer layouts afterward. That +encoder also owns the exact pipeline barriers, semaphore dependencies, queue +family transfers, and reuse rules required by its schedule. Vulkan's implicit +external dependencies are not treated as a complete frame-scheduling policy. + +`vkCreateRenderPass` does not consume clear values. The owner publishes the +ordered five-value clear array as metadata for a future pass-begin owner: + +- color slots 0 through 2 clear to transparent black; +- widened logical-RGB slot 3 clears to `(0, 0, 0, 1)`; and +- depth clears to 1 with stencil 0. + +The owner retains the complete immutable attachment profile, including slot +3's RGB-only write mask and `ImplicitOneAfterClear` alpha semantic. Alpha-one +correctness remains non-executable until a later graphics pipeline consumes +that write mask. + +## Device, ownership, and failure contract + +The caller supplies borrowed physical and logical devices plus injected +`vkCreateRenderPass` and `vkDestroyRenderPass` callbacks. It guarantees a +Vulkan 1.1 logical device created from the supplied physical device, at least +one graphics-capable queue family, callbacks valid for that device, handle and +callback lifetime through owner destruction, externally synchronized host +access, and completion of every submitted native user before destruction. +Raw handles cannot authenticate those relationships. + +Null physical or logical devices, incomplete dispatch, and a profile selected +for another physical device fail before a native callback. The noncopyable, +nonmovable C++ owner and retained profile are allocated before native create. +A failed `VkResult` ignores any poisoned output. Success with a null handle is +rejected. Only one non-null success is published. + +Destruction uses the creating device, retained destroy callback, and null +allocator exactly once. Independent successful calls retain independent +destruction obligations even when their opaque handle values compare equal. +Owner allocation failure has a typed result and is structurally ordered before +native creation, but deterministic allocation failure is not fault-injected. + +## Focused tests and review + +Six fake-dispatch cases cover: + +- result types, fixed attachment count, copy/move/destruction traits, profile + nothrow-copy, and public `noexcept` boundaries; +- every preflight rejection with zero native callbacks; +- exact Modern HDR and Compatibility attachment order and every create-info, + attachment, reference, subpass, optional-pointer, and dependency field; +- retained provenance, profile, write mask, alpha semantic, layouts, and clear + values after the source result is replaced; +- failed creation with poisoned output and success with a null handle; and +- exact destruction, including independent owners that deliberately reuse an + opaque handle value. + +The fake proves host-side call formation, rollback, metadata, and ownership. +It cannot establish the physical/logical device relationship, graphics queue +capability, real-driver acceptance, concrete image validity, synchronization, +or submitted lifetime. Those remain explicit caller preconditions or later +runtime gates. + +Four independent adversarial review passes corrected the original mixed +automatic-transition policy, made the deferred synchronization and layout +contract public, added the graphics-queue precondition, completed the owner +trait and Compatibility alpha assertions, and clarified explicit versus +implicit dependencies. The final reviewed source has no remaining medium- or +high-severity issue. + +## Linux evidence + +The Release viewer, production shader chain, render-pass archive, integration +test, and affected targets built with project warnings treated as errors. All +six new cases and the exact 16 focused CTest routes passed. The related Python +suites passed all 12 reflection, eight artifact-delivery, and 57 +benchmark-harness tests. No benchmark scenario or timing ran. + +The archive and integration executable have no direct +`vkCreateRenderPass` or `vkDestroyRenderPass` symbol. The focused executable +has no Vulkan, MoltenVK, OpenGL, window-system, SDL, or viewer dynamic +dependency. Both production SPIR-V modules retained their established hashes. + +With the option disabled, the new render-pass target and all other opt-in +material targets were absent, the Release viewer built, and it retained no +Vulkan or MoltenVK linkage. Restoring the option rebuilt the focused target +and production shader chain. This Linux tree has `PACKAGE=OFF`; its broader +viewer-manifest copy graph also references inherited media-plugin outputs not +present in this build graph, so it is not used as package evidence. The eight +delivery tests and the fresh macOS ON/OFF package gate cover the artifact +delivery boundary. + +## macOS evidence + +A user-owned disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK 26.5 +contained the Stage 25 commit plus exactly the four reviewed Stage 26 source, +test, and build files. Final snapshot hashes matched the local working tree: + +| File | SHA-256 | +| --- | --- | +| `indra/llrender/CMakeLists.txt` | `f005839dfc6e705bd9c91b3b6a93e23dcfb9a5ebbe58a269e58950e13e8b764f` | +| Render-pass header | `283b7811b8a8b855f4ecfdd14bb88869159fc9583f9325a396028b0226f1d720` | +| Render-pass implementation | `3cca4050d7ead6ad06cd618eedf967c0d3a25b183ca6844fa3367c552334f1f8` | +| Fake-dispatch test | `de7ff7eba84f0c41eef1b070986a85fdf889c7c0dba439d94059098dde76c6cb` | + +The option-on ReleaseOS gate used `arm64;x86_64`, deployment target 11, +tests, packaging, and warnings as errors, with renderer benchmarks, signing, +and crash reporting disabled. The full universal viewer and package passed. +The exact 16 focused CTest routes, all six new cases, 12 reflection cases, +eight artifact-delivery cases, and 57 benchmark-harness tests passed. + +The render-pass archive contains both architecture slices. The focused test is +native arm64 under existing project policy and has a valid code signature. The +archive and test have no direct render-pass symbol, and the test has no Vulkan, +MoltenVK, OpenGL, SDL, or viewer dependency. The option-on app contained +exactly the two canonical production modules: + +| Module | SHA-256 | +| --- | --- | +| Vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | +| Fragment | `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb` | + +All 367 packaged Mach-O files were readable and had no Vulkan or MoltenVK +linkage. After an option-off reconfigure, all 21 enumerated opt-in material +targets were absent while the neutral stale-artifact cleanup route remained. +A fresh universal viewer and package passed, the app contained zero SPIR-V +files, and all 367 Mach-O files remained readable and free of Vulkan and +MoltenVK linkage. + +The first option-on build and package succeeded, but Autobuild metadata then +failed because the synthetic disposable Git snapshot lacked an origin URL. +Adding the repository's public origin as harness-only metadata and rerunning +incrementally passed without a source change or weakened check. + +The gate did not launch the viewer, create a graphics context, call a driver, +use an account, enter a region, compare pixels, or collect timing. The exact +disposable root was revalidated as user-owned and non-symlinked, deleted, and +verified absent. + +## Code size + +| Stage 26 code, excluding this decision record | Lines added | Lines removed | +| --- | ---: | ---: | +| Render-pass backend header and implementation | 294 | 0 | +| Fake-dispatch render-pass tests | 514 | 0 | +| Opt-in build wiring | 24 | 0 | +| Total | 832 | 0 | + +## Explicit runtime gaps + +Stage 26 does not acquire Vulkan loader dispatch, create an instance, select a +physical device, create a logical device or queues, call a real driver, +allocate memory or images, validate a concrete extent or resource size, create +an image view or framebuffer, permit attachment aliasing, create a graphics +pipeline or cache, record begin/end commands, transition layouts, establish +barriers or semaphore dependencies, submit work, synchronize completion, +handle device loss, connect a viewer call site, select a backend, present or +compare pixels, or measure performance. + +The render pass is a compatibility object and ownership transaction, not a +working rendering path. The smallest next dependency remains open until this +stage is committed and the dependency graph is reanalysed. diff --git a/doc/performance/renderer-stage-27-decision.md b/doc/performance/renderer-stage-27-decision.md new file mode 100644 index 00000000000..c63275562b3 --- /dev/null +++ b/doc/performance/renderer-stage-27-decision.md @@ -0,0 +1,201 @@ +# Stage 27 Vulkan independent-blend capability decision + +## Decision + +Strengthen the existing material attachment-profile resolver so every +successful production profile proves physical support for Vulkan's optional +`independentBlend` feature. Retain the matching logical-device enablement +requirement as typed profile data. + +This is the twelfth committable slice of master Stage 2. It changes the Stage +25 resolver and its existing opt-in test target. It creates no Vulkan object, +adds no build target, links no loader, and does not start graphics-pipeline +work. + +## Why the feature is required + +The material attachment profile retains four color write masks. Slots 0 +through 2 write RGBA. Slot 3 writes RGB so its alpha remains one after a clear +to `(0, 0, 0, 1)`, even though the production fragment shader writes zero to +that alpha component. + +Vulkan requires every `VkPipelineColorBlendAttachmentState` to be identical +unless the logical device enables +[`independentBlend`](https://docs.vulkan.org/refpages/latest/refpages/source/VkPipelineColorBlendStateCreateInfo.html). +The fourth write mask makes the states unequal even though blending itself is +disabled. `independentBlend` is an optional member of +[`VkPhysicalDeviceFeatures`](https://docs.vulkan.org/refpages/latest/refpages/source/VkPhysicalDeviceFeatures.html), +so the Stage 25 format and limit queries were not enough to establish that a +future pipeline can consume the retained masks. + +Changing slot 3 to an RGBA write mask would break the established alpha-one +invariant. Changing the shared shader output would also change the accepted +production artifact. Stage 27 keeps both target mappings, all four masks, and +the shader artifacts unchanged. + +## Query and failure contract + +`MaterialAttachmentDispatch` now includes injected +`PFN_vkGetPhysicalDeviceFeatures`. Missing feature dispatch returns +`InvalidDispatch` before any callback, like every other incomplete dispatch. +Null device, malformed key, and unsupported target-profile checks also remain +ahead of native queries. + +After preflight, the resolver performs this fixed transaction: + +1. query `VkPhysicalDeviceFeatures` once; +2. reject a false `independentBlend` value; +3. query physical-device properties once; and +4. query format properties followed by image-format properties for each of + the four ordered color roles and the depth role. + +Feature rejection returns `MissingDeviceFeature` with +`PhysicalDeviceFeatures` query context and the `IndependentBlend` feature +discriminator. It performs no property, format, or image-format query and +publishes no partial profile. + +## Support, requirement, and provenance + +A successful profile now establishes two related facts: + +- the injected query reported `independentBlend` support for the exact + physical device recorded by `selectedFor()`; and +- `deviceRequirements()` returns an immutable copy whose + `independentBlendRequired()` accessor returns true. Its state is private and + the value is neither an aggregate nor publicly default-constructible. + +These facts are deliberately separate. A physical feature query cannot prove +which features an existing `VkDevice` enabled. The profile does not +authenticate a physical-to-logical-device relationship, mint a logical-device +capability token, or make the write masks executable. A later device owner +must enable the retained requirement, and a later pipeline owner must consume +an authenticated view of those enabled features. + +The existing format, role, capability, clear, alpha, target-profile, and +physical-device provenance data remain unchanged. `selectedFor()` still +answers only whether the profile came from a particular physical device. + +## Focused tests + +The attachment fake defaults `independentBlend` to true and records the full +callback sequence. Nine attachment cases now cover: + +- result traits and the typed requirement value; +- every invalid input and missing callback before all native queries; +- Modern HDR and Compatibility success with one early feature query, exact + callback order, retained requirement, unchanged formats, roles, masks, + clears, capabilities, and physical provenance; +- an unsupported feature with exact error context and zero later callbacks; +- both physical limits; +- every required optimal-tiling feature for every attachment role; +- every ordered native image-format failure; and +- every separate image-capability failure. + +The Stage 26 render-pass fixture supplies the new callback. Its six existing +cases also check that both target-profile owners retain the strengthened +requirements value after copying the source profile. + +The fake proves query ordering, typed failure, retained metadata, and absence +of later callbacks after feature rejection. It does not prove that a driver +supports the feature or that a logical device enabled it. + +## Focused Linux evidence + +The Release configuration had `LL_VULKAN_TONEMAP_TEST=ON` and treated project +warnings as errors. The viewer, production shader chain, attachment and +render-pass libraries, and their integration executables built successfully. +All nine attachment cases and all six dependent render-pass cases passed. + +The established 16-route focused CTest set passed, including the draw-packet, +diagnostic, contract, manifest, artifact, publication, module, layout, +descriptor, attachment, render-pass, registry, delivery, manifest-argument, +and parity-argument routes. The 12 reflection tests, eight artifact-delivery +tests, and 57 benchmark-harness unit tests also passed. The harness tests did +not run a benchmark. + +The production modules retained their canonical hashes: + +| Module | SHA-256 | +| --- | --- | +| Vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | +| Fragment | `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb` | + +The focused archives and executables have no direct physical-device query or +render-pass symbols. Their dynamic dependencies contain no Vulkan, MoltenVK, +OpenGL, SDL, X11, Wayland, or viewer dependency. A full option-off reconfigure +removed all enumerated opt-in material targets from the generated graph while +leaving the neutral stale-artifact cleanup route. The Release viewer rebuilt +without Vulkan or MoltenVK linkage. The option was restored before the final +focused build and test pass. + +The tests use only injected callbacks. They did not load Vulkan, query a real +physical device, create a logical device, launch the viewer, enter a region, +render pixels, or collect timing. + +## macOS evidence + +A user-owned disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK 26.5 +contained the Stage 26 commit plus exactly the six reviewed Stage 27 files. +The final resolver, dependent-test, and Stage 25 correction hashes matched the +local working tree before the gate was removed. + +The option-on ReleaseOS gate used `arm64;x86_64`, deployment target 11, tests, +packaging, and warnings as errors, with renderer benchmarks, signing, and +crash reporting disabled. The full universal viewer and package passed. The +exact 16 focused CTest routes, all nine attachment cases, all six render-pass +cases, 12 reflection tests, eight artifact-delivery tests, and 57 +benchmark-harness unit tests passed. + +Both focused archives contain x86_64 and arm64 slices. The two focused test +executables are native arm64 under existing project policy and have valid +ad-hoc signatures. Neither the archives nor tests have direct physical-device +query or render-pass symbols, and the tests have no Vulkan, MoltenVK, OpenGL, +SDL, X11, or Wayland dependency. The option-on app contained exactly the two +canonical production modules listed above. All 367 packaged Mach-O files were +readable and had no Vulkan or MoltenVK linkage. + +After a full option-off reconfigure, all 21 enumerated opt-in material targets +were absent while the neutral stale-artifact cleanup target remained. A fresh +universal viewer and package passed, the app contained zero SPIR-V files, and +all 367 Mach-O files remained readable and free of Vulkan and MoltenVK +linkage. + +The first disposable configure paired the Xcode generator with a Ninja +make-program override. Its fresh build directory was discarded and recreated +without that mismatch. A direct configure was then rerun from the snapshot +root so its synthetic Git revision was available. These harness corrections +did not change source or weaken a gate. + +The macOS gate did not launch the viewer, create a graphics context, call a +driver, use an account, enter a region, compare pixels, run a benchmark, or +retain timing. The exact disposable root was revalidated as user-owned and +non-symlinked, deleted, and verified absent. + +An independent review found that the first requirements value was publicly +mutable. Its state and construction were made private, a read-only accessor +and type-boundary tests were added, and the final review reported no medium or +high issue. + +## Code size + +| Stage 27 change, excluding this decision record | Lines added | Lines removed | +| --- | ---: | ---: | +| Attachment resolver API and implementation | 54 | 4 | +| Attachment fake-dispatch tests | 112 | 23 | +| Dependent render-pass fixture | 22 | 4 | +| Stage 25 decision correction | 49 | 18 | +| Total | 237 | 49 | + +## Explicit runtime gaps + +Stage 27 does not obtain dispatch from `vkGetInstanceProcAddr`, create a Vulkan +instance or logical device, select queues, enable a device feature, create a +pipeline layout or graphics pipeline, allocate images or memory, create views +or a framebuffer, record commands, establish transitions or synchronization, +submit work, present, handle device loss, connect a viewer route, compare +pixels, or measure performance. + +Native graphics-pipeline ownership remains a dependent stage. Its device input +must carry authenticated logical-feature enablement, preserve the Vulkan 1.1 +pipeline-layout lifetime, consume the resolved slot masks, and handle +partially successful pipeline creation correctly. diff --git a/doc/performance/renderer-stage-28-decision.md b/doc/performance/renderer-stage-28-decision.md new file mode 100644 index 00000000000..c43ffe67fc8 --- /dev/null +++ b/doc/performance/renderer-stage-28-decision.md @@ -0,0 +1,351 @@ +# Stage 28 Vulkan material pipeline-capability decision + +## Decision + +Accept one opt-in, backend-private resolver that combines an immutable Stage 27 +attachment profile with the remaining physical-device requirements for the +canonical production material pipeline. The resolver returns either one typed +error or one immutable `LegacyNormSpecPipelineCapabilityProfile` selected for +the same `VkPhysicalDevice` as its retained attachment profile. + +This is the thirteenth committable slice of master Stage 2. It closes the +physical shader and pipeline-capability contract before native graphics-pipeline +creation. The library and its focused integration test remain behind +`LL_VULKAN_TONEMAP_TEST`, use injected dispatch with `VK_NO_PROTOTYPES`, and do +not link a Vulkan loader or expose Vulkan through the neutral renderer contract. + +The original renderer-modernization plan places a portability-aware Vulkan +prototype in master Stage 2. This slice stays within that boundary. It does not +pull master Stage 3 instance, surface, WSI-aware device, swapchain, or whole-frame +ownership forward. The invalid benchmark baseline remains paused without a +performance conclusion. + +## Separate composed profile + +Stage 25 owns attachment formats, image roles, capabilities, clears, color write +masks, and alpha semantics. Stage 27 added the `independentBlend` requirement +caused by those unequal write masks. Stage 28 does not add unrelated queue, +extension, or vertex policy to that attachment resolver. + +Instead, the new resolver accepts: + +- a non-null borrowed `VkPhysicalDevice`; +- one already resolved `LegacyNormSpecAttachmentProfile`; and +- injected physical properties, queue-family, device-extension, and properties2 + callbacks. + +It rejects an attachment profile selected for another physical device before +calling any callback. A successful profile retains a copy of the exact +attachment profile, the accepted Vulkan API version, seven canonical vertex +records, the portability stride alignment, and read-only logical-device +requirements. Callers cannot combine attachment facts from one physical device +with pipeline facts from another. + +The profile is private to the resolver's construction path. It is neither an +aggregate nor publicly default-constructible. Its accessors return read-only +state or immutable copies, so callers cannot turn a successful result into an +unsupported configuration. + +## Vulkan 1.1 core contract + +The resolver queries `VkPhysicalDeviceProperties::apiVersion`, rejects a +nonzero API variant, and then rejects a standard Vulkan device below 1.1. A raw +packed-version comparison is not sufficient because the variant occupies the +highest bits. This proves the physical device advertises the required floor; it +does not prove that a parent instance requested Vulkan 1.1. The future instance +owner must authenticate that separate creation input. The floor matches the +production modules, which target SPIR-V 1.3. Vulkan 1.1 requires support for +SPIR-V 1.3 and earlier, as described in the official +[Vulkan versions guide](https://docs.vulkan.org/guide/latest/versions.html#_spir_v). + +The accepted API floor also supplies fixed minimum limits. Stage 28 does not +turn those guarantees into redundant driver queries. Focused agreement tests +bind the retained records to the production shader manifest and the existing +pipeline-layout contract. The relevant Vulkan 1.1 guarantees are documented in +the [physical-device limits table](https://docs.vulkan.org/spec/latest/chapters/limits.html#limits-minmax). + +The canonical vertex state is: + +| Semantic | Location | Binding | Offset | Stride | Native Vulkan format | +| --- | ---: | ---: | ---: | ---: | --- | +| Position | 0 | 0 | 0 | 16 | `VK_FORMAT_R32G32B32_SFLOAT` | +| Normal | 1 | 1 | 0 | 16 | `VK_FORMAT_R32G32B32_SFLOAT` | +| Texture coordinate 0 | 2 | 2 | 0 | 8 | `VK_FORMAT_R32G32_SFLOAT` | +| Color | 3 | 3 | 0 | 4 | `VK_FORMAT_R8G8B8A8_UNORM` | +| Tangent | 4 | 4 | 0 | 16 | `VK_FORMAT_R32G32B32A32_SFLOAT` | +| Texture coordinate 1 | 5 | 5 | 0 | 8 | `VK_FORMAT_R32G32_SFLOAT` | +| Texture coordinate 2 | 6 | 6 | 0 | 8 | `VK_FORMAT_R32G32_SFLOAT` | + +These seven attributes and bindings fit the Vulkan 1.1 core minima. Their +largest offset is zero and their largest stride is 16. The four formats have +mandatory `VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT` support in the +[required format-support table](https://docs.vulkan.org/spec/latest/chapters/formats.html#formats-required-features). +The resolver therefore does not query vertex-format properties. + +The production contract also stays below the Vulkan 1.1 minimums for: + +- eight interstage locations carrying 20 components; +- two descriptor sets; +- one 272-byte uniform block visible to vertex and fragment stages; +- three fragment-stage combined image samplers; +- four fragment outputs and four combined fragment-output resources; and +- one viewport, one sample, and no push constants. + +The accepted SPIR-V modules declare only the `Shader` capability. They do not +require optional geometry, tessellation, numeric-width, subgroup, storage, +atomic, sample-rate, clip-distance, or cull-distance features. Triangle-list +topology, fill mode, back-face culling, counter-clockwise front faces, depth +test and write with `LESS_OR_EQUAL`, disabled stencil and logic operations, +line width one, and dynamic viewport and scissor also need no optional feature. + +`independentBlend` remains the one optional core feature required by the fixed +pipeline. Slots 0 through 2 use RGBA write masks while slot 3 uses RGB. Vulkan +requires identical color-blend attachment states unless the logical device +enables [`independentBlend`](https://docs.vulkan.org/refpages/latest/refpages/source/VkPhysicalDeviceFeatures.html). +Stage 28 copies that established Stage 27 obligation. It does not query the +feature again. + +## Variable physical-device queries + +After input and dispatch preflight, the resolver performs a fixed transaction: + +1. query physical-device properties, reject nonstandard variants, and reject a + standard API version below 1.1; +2. enumerate queue-family properties and require one non-empty family with + `VK_QUEUE_GRAPHICS_BIT`; +3. enumerate device extensions with a null layer name and detect only the full + `VK_KHR_portability_subset` name; and +4. if that extension is advertised, query its stride-alignment property through + `vkGetPhysicalDeviceProperties2`. + +[`vkCreateGraphicsPipelines`](https://docs.vulkan.org/refpages/latest/refpages/source/vkCreateGraphicsPipelines.html) +requires its device to support at least one graphics-capable queue family. The +profile records that requirement and the resolver proves that such a family +exists. It does not retain a queue-family index. Presentation support depends on +a future surface, so a later WSI-aware device owner must choose and authenticate +the actual family used to create the logical device. + +Queue-family and extension enumeration use checked, nonthrowing scratch +allocation. A returned count that cannot fit the allocation size fails before +allocation, allocation failure has a typed result, and a second queue-family +query cannot publish more records than the first query provided room for. + +Device-extension enumeration retries the complete count-and-list transaction at +most three times when either call returns `VK_INCOMPLETE`. It preserves the +failing `VkResult` and attempt number, rejects a returned list count above its +allocated capacity, and fails closed after the retry bound. The public resolver +remains `noexcept`; no partial profile escapes any query, enumeration, or +allocation failure. The Vulkan enumeration rules are documented with +[`vkEnumerateDeviceExtensionProperties`](https://docs.vulkan.org/refpages/latest/refpages/source/vkEnumerateDeviceExtensionProperties.html). + +## Portability-subset policy + +The [`VK_KHR_portability_subset`](https://docs.vulkan.org/refpages/latest/refpages/source/VK_KHR_portability_subset.html) +device extension changes which otherwise-core behavior a portability +implementation must provide. If a physical device advertises the extension, a +future `VkDeviceCreateInfo` must enable it. This is a Vulkan valid-usage rule, +not an optional viewer preference. The rule appears in the +[`VkDeviceCreateInfo` valid usage](https://docs.vulkan.org/refpages/latest/refpages/source/VkDeviceCreateInfo.html). + +When the extension is absent, the profile records no extension obligation and +uses the neutral stride alignment value one. It does not interpret +portability-only properties. When the extension is present, the resolver chains +`VkPhysicalDevicePortabilitySubsetPropertiesKHR` into a Vulkan 1.1 +`VkPhysicalDeviceProperties2` query and retains +`minVertexInputBindingStrideAlignment`. + +The reported alignment must be a nonzero power of two. Every canonical stride, +`16, 16, 8, 4, 16, 8, 8`, must be at least the alignment and evenly divisible by +it. A mismatch reports the first offending binding and its stride. This enforces +the portability valid-usage rule for +[`VkVertexInputBindingDescription`](https://docs.vulkan.org/refpages/latest/refpages/source/VkVertexInputBindingDescription.html) +before a native pipeline can consume the profile. The four-byte color stride is +the tightest accepted record. + +Stage 28 requires no member of +[`VkPhysicalDevicePortabilitySubsetFeaturesKHR`](https://docs.vulkan.org/refpages/latest/refpages/source/VkPhysicalDevicePortabilitySubsetFeaturesKHR.html) +to be true. In particular, it does not require or enable +`vertexAttributeAccessBeyondStride`. Every attribute starts at offset zero, and +the format sizes fit their strides: `12/16, 12/16, 8/8, 4/4, 16/16, 8/8, 8/8`. +The fixed pipeline also avoids constant-alpha blend factors, triangle fans, +point polygon mode, stencil state that needs separate masks or references, +sample-rate interpolation functions, and tessellation modes. + +Portability features concerning events, image-view reinterpretation or swizzle, +2D views of 3D images, multisampled array images, comparison samplers, and +sampler LOD bias belong to later resource, sampler, and command owners. Those +owners must choose states valid when the corresponding portability feature is +false. Stage 28 neither queries nor enables unused feature bits. + +`VK_KHR_portability_enumeration` and +`VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR` may be needed for a future +instance owner to expose a MoltenVK physical device. They precede this resolver +and remain outside this stage. Stage 28 can assess only a physical device that a +caller has already enumerated. + +## Physical support and future device authentication + +A successful Stage 28 profile proves physical support at query time. It also +publishes the requirements a future logical-device transaction must satisfy: + +- enable the retained `independentBlend` core feature; +- create at least one queue from a graphics-capable family; and +- enable `VK_KHR_portability_subset` if the profile records that it was + advertised. + +This is not proof that an existing `VkDevice` enabled any of them. Vulkan does +not provide a query that reconstructs an arbitrary device's creation state. +Only the future owner of the exact `VkDeviceCreateInfo` can publish an +authenticated enabled-feature, enabled-extension, and created-queue view. A +later graphics-pipeline owner must consume that view, the retained capability +profile, shader modules, pipeline layout, and render pass from the same device +generation. + +The future instance owner must likewise prove that its exact +`VkApplicationInfo::apiVersion` selected standard Vulkan 1.1 or newer. A +physical device advertising 1.1 cannot raise an instance created against a +lower API ceiling. + +## Focused tests and review + +Nine new fake-dispatch cases cover result traits; every input and dispatch +preflight; physical provenance; nonzero API variants and Vulkan 1.0 rejection; +both production attachment-profile successes; the retained manifest, core +floor, vertex records, and logical-device requirements; graphics-queue absence, +growth, and success; bounded extension enumeration and every failure class; +exact and decoy portability-extension names; the properties2 chain; and valid, +invalid, and incompatible portability alignments. The nine attachment and six +render-pass cases also pass, for 24 directly dependent C++ cases. The exact +focused set contains 17 CTest routes. + +Scratch-allocation failure is structurally reviewed through checked size +arithmetic and `new (std::nothrow)`; the stage does not add a test-only allocator +or attempt a destructive real out-of-memory test. + +Independent implementation, fake-dispatch test, gate, and final Vulkan +portability/specification reviews corrected five material issues: raw packed API +comparison now rejects nonzero variants; failed extension enumeration no longer +reads undefined output counts; successful and incomplete list results both +validate capacity before retry or publication; the decision distinguishes a +physical API floor from future instance authentication; and the allocation +claim no longer implies an unperformed out-of-memory test. Final review found no +unresolved medium- or high-severity issue. The low residual is the structurally +reviewed allocation-failure branch on a 64-bit host. + +Fake dispatch can prove host-side query formation, ordering, bounded failure, +typed errors, immutable publication, and provenance. It cannot prove that a real +driver reports the faked values, that a logical device enabled the recorded +requirements, or that a native graphics pipeline accepts the later create info. + +## Linux evidence + +The Release cache enabled tests and `LL_VULKAN_TONEMAP_TEST` and treated project +warnings as errors. The viewer, production shader chain, attachment, capability, +and render-pass archives, and all three focused integration executables built. +All nine new cases and the exact 17 focused CTest routes passed. The 12 +reflection, eight artifact-delivery, and 57 benchmark-harness unit tests also +passed. The last suite exercised only Python harness logic; it did not launch a +benchmark. + +Formatting, diff, five-file source-boundary, path/privacy, and backend-include +scans passed. Neither the capability archive nor its executable contains a +direct physical-properties, queue-family, device-extension, or properties2 +query symbol. The executable has no Vulkan, MoltenVK, OpenGL, SDL, +window-system, or viewer dynamic dependency, and the viewer has no Vulkan or +MoltenVK dependency. + +With the option disabled, all 23 enumerated material targets were absent while +the neutral stale-artifact cleanup target remained. The Release viewer rebuilt +without Vulkan or MoltenVK linkage. The cache was restored to option-on and the +17 focused routes passed again. This Linux tree has packaging disabled, so it +is not used as package evidence. + +No test loaded Vulkan, queried a driver, launched the viewer, used an account or +region, rendered pixels, ran a benchmark scenario, or retained timing. + +## macOS evidence + +A user-owned disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK 26.5 +contained the Stage 27 commit plus exactly the five planned Stage 28 files. It +had exactly two synthetic commits and the intended five-file delta. The four +compiled source and build-input hashes matched before configuration, after both +build modes, and against the local candidate before deletion. This decision +record was then completed locally from the observed gate evidence; it is not a +compiled input. + +The option-on ReleaseOS gate used deployment target 11, `arm64;x86_64`, tests, +packaging, and warnings as errors, with renderer benchmarks, signing, crash +reporting, and proprietary components disabled. The full 202-target universal +build and package passed, as did the explicit viewer, package, production shader, +attachment, capability, and render-pass targets. The exact 17 focused CTest +routes, all nine capability cases, 12 reflection tests, eight artifact-delivery +tests, and 57 benchmark-harness unit tests passed. + +The attachment, capability, and render-pass archives contain both architecture +slices. Their three focused executables are native arm64 under existing project +policy, have valid signatures, and have no Vulkan, MoltenVK, OpenGL, SDL, X11, +Wayland, or viewer dependency. The capability archive has no direct physical +query symbol. The universal app contained exactly two production SPIR-V files, +byte-identical to the build outputs and carrying the canonical hashes below. +All 367 packaged Mach-O files were readable and free of Vulkan or MoltenVK +linkage. + +After option-off reconfiguration, the full 168-target universal build and +package passed. All 23 enumerated opt-in targets were absent, the neutral cleanup +target remained, the app contained zero SPIR-V files, and all 367 Mach-O files +remained readable and dependency-clean. + +Two harness-only corrections were needed. The synthetic repository inherited a +global SSH commit-signing preference, so only its stalled disposable commit was +terminated and signing was disabled locally there. Xcode 26 also requires the +input file before `-verify_arch`, so that read-only `lipo` check was rerun with +the accepted argument order. Neither correction changed source or weakened a +gate. + +The gate did not launch the viewer or a sample, call a GPU or driver, set a +Vulkan runtime environment, use an account or region, compare pixels, run a +benchmark, or retain timing. The exact disposable root was revalidated as +user-owned and non-symlinked, deleted, and independently verified absent. + +## Production artifact hashes + +| Module | SHA-256 | +| --- | --- | +| Vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | +| Fragment | `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb` | + +## Code size + +| Stage 28 code, excluding this decision record | Lines added | Lines removed | +| --- | ---: | ---: | +| Capability resolver header and implementation | 534 | 0 | +| Fake-dispatch capability tests | 849 | 0 | +| Opt-in build wiring | 24 | 0 | +| Total | 1,407 | 0 | + +## Commit boundary + +This decision, the resolver header and implementation, its focused test, and the +adjacent build wiring form one Stage 28 commit whose parent is +`731d46e1ff09380355be299fa4c8be02bdc88f27`. The unrelated user documents are +not part of the boundary. + +## Explicit runtime gaps + +Stage 28 does not obtain callbacks from `vkGetInstanceProcAddr`, create or own a +Vulkan loader, instance, debug messenger, surface, physical device, logical +device, queue, shader module, descriptor layout, pipeline layout, render pass, +pipeline cache, or graphics pipeline. It does not choose a presentation-capable +queue family or authenticate an existing logical device. + +It also does not allocate memory, buffers, images, views, samplers, descriptors, +or framebuffers; validate a concrete render extent or memory size; record render +pass begin or draw commands; transition layouts; establish barriers, semaphores, +or queue-family transfers; submit work; synchronize completion; handle device +loss; present; connect a viewer route; select a runtime backend; compare pixels; +or collect timing. + +The profile closes one physical capability boundary. It is not a working Vulkan +renderer. Native graphics-pipeline ownership remains dependent on a future +authenticated logical-device view and on same-device shader-module, layout, and +render-pass lifetimes. diff --git a/doc/performance/renderer-stage-29-decision.md b/doc/performance/renderer-stage-29-decision.md new file mode 100644 index 00000000000..52f0301066f --- /dev/null +++ b/doc/performance/renderer-stage-29-decision.md @@ -0,0 +1,306 @@ +# Stage 29 Vulkan material pipeline-cache decision + +## Decision + +Accept one opt-in, backend-private owner for an empty in-memory +`VkPipelineCache`. The factory accepts one exact borrowed logical device and +injected create and destroy callbacks. It returns either one typed error or one +unique owner for a non-null cache handle. + +This is the fourteenth committable slice of master Stage 2. The library and its +focused integration test remain behind `LL_VULKAN_TONEMAP_TEST`, use injected +dispatch with `VK_NO_PROTOTYPES`, and do not link a Vulkan loader or expose +Vulkan through the neutral renderer contract. + +The original renderer-modernization plan places pipeline caches in master +Stage 2 and instance, surface, and logical-device ownership in master Stage 3. +Stage 29 follows that boundary. It defines ownership for one native Vulkan +object type without creating it on a real driver or claiming that the supplied +device satisfies the Stage 28 material-pipeline requirements. + +## Why the graphics pipeline waits + +Stage 28 proved physical support and retained the obligations for a future +logical device: + +- enable `independentBlend`; +- create at least one graphics-capable queue; and +- enable `VK_KHR_portability_subset` when the physical device advertises it. + +A raw `VkDevice` does not reveal the `VkDeviceCreateInfo` that created it. +Wrapping the handle with caller-provided booleans would rename the assumption, +not authenticate it. A native graphics-pipeline owner must instead consume a +view minted by the owner of the exact device-create transaction. + +That transaction remains in master Stage 3. Its instance owner must retain the +exact standard Vulkan 1.1-or-newer `VkApplicationInfo::apiVersion`. Its +WSI-aware device owner must choose queues against a real surface, enable the +Stage 28 feature and extension obligations, and retain the exact device +generation. Presentation support cannot be selected before the surface exists. + +An empty pipeline cache has none of those requirements. Core +[`vkCreatePipelineCache`](https://docs.vulkan.org/refpages/latest/refpages/source/vkCreatePipelineCache.html) +accepts a cache with no initial data. Flags zero require no optional feature, +and cache creation itself needs no graphics queue. The cache can therefore be +owned now without weakening the later pipeline boundary or pulling Stage 3 +runtime ownership forward. + +## Borrowed logical-device boundary + +`MaterialPipelineCacheDevice` contains one `VkDevice` and a +`MaterialPipelineCacheDispatch` with exactly: + +- `PFN_vkCreatePipelineCache`; and +- `PFN_vkDestroyPipelineCache`. + +The factory rejects a null device or either missing callback before allocation +or a native call. It does not accept a physical device, capability profile, +queue, loader, instance, or global dispatch table. + +The caller owns the logical device and the implementation addressed by the two +callbacks. Both remain valid until the returned owner is destroyed. The caller +also prevents destruction while another host operation uses the cache. This is +particularly important once a later stage passes the cache to pipeline +creation. Vulkan's +[`vkDestroyPipelineCache`](https://docs.vulkan.org/refpages/latest/refpages/source/vkDestroyPipelineCache.html) +contract requires host access to the cache to be externally synchronized. + +`MaterialPipelineCache` is noncopyable and nonmovable. It exposes the +borrowed `VkPipelineCache` handle by value and answers whether it was +`createdOn(VkDevice)`. It retains only the creating device, destroy callback, +and cache handle. It stores no pointer into temporary create information. + +The owner is intentionally device-wide rather than bound to one Stage 28 +target profile. A future graphics-pipeline owner will compose the cache with an +authenticated device generation, the exact Stage 28 capability profile, shader +modules, pipeline layout, and render pass. The cache does not claim those +objects or facts itself. + +## Exact cold creation + +The factory submits one fixed +[`VkPipelineCacheCreateInfo`](https://docs.vulkan.org/refpages/latest/refpages/source/VkPipelineCacheCreateInfo.html): + +- `sType` is `VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO`; +- `pNext` is null; +- `flags` is zero; +- `initialDataSize` is zero; and +- `pInitialData` is null. + +The call also passes null allocation callbacks. No extension structure, +initial cache bytes, application allocator, or optional synchronization flag is +accepted through the public API. + +Flags zero leave pipeline-cache access under Vulkan's default synchronization +behavior for later pipeline-creation calls. Stage 29 does not choose an +external-synchronization mode or define concurrent pipeline compilation. The +owner's lifetime rule still requires the caller to prevent cache destruction +during any use. + +## Transaction and destruction + +The factory allocates the C++ owner with `new (std::nothrow)` before calling +Vulkan. This order prevents a successful native cache from being stranded by a +later owner-allocation failure. Allocation failure returns its own typed error +and performs no native call. + +After allocation, the factory calls the injected create callback once with the +exact borrowed device, fixed create info, null allocation callbacks, and a local +output handle. + +Only `VK_SUCCESS` plus a non-null output establishes ownership. A failed +`VkResult` is preserved in the typed error. Any output bits written by a failed +callback are ignored and never destroyed because failure does not publish +ownership. `VK_SUCCESS` with a null output returns a separate typed error. + +Once success is validated, the factory stores the handle in the preallocated +owner and returns it. Destruction calls the captured destroy callback exactly +once with the creating device, owned cache, and null allocation callbacks. +Independent successful owners retain independent destruction obligations even +when their opaque cache-handle values compare equal. + +The result distinguishes: + +- invalid logical device; +- incomplete dispatch; +- owner-allocation failure; +- native creation failure with the exact `VkResult`; and +- native success with a null cache handle. + +No partial owner or native handle escapes a failed transaction. + +## Cold-cache limit + +Stage 29 proves only cold creation and ownership. It does not call +[`vkGetPipelineCacheData`](https://docs.vulkan.org/refpages/latest/refpages/source/vkGetPipelineCacheData.html), +accept initial data, export or import bytes, validate cache headers or UUIDs, +merge caches, write files, or define invalidation policy. + +It also creates no pipeline that could populate the cache. Fake dispatch can +prove create-info shape, failure handling, and lifetime, but it cannot prove a +driver cache hit, a warm reopen, reduced compilation work, or a performance +benefit. The master Stage 2 exit gate for tested cold and warm behavior remains +open until a real pipeline consumer and cache-data policy exist. + +## Focused tests and review + +Six focused fake-dispatch cases cover: + +- owner and result copy, move, and destruction traits; +- the public factory's `noexcept` boundary; +- a null logical device and each missing callback, all with zero native calls; +- every fixed create-info field, the exact logical device, and null allocation + callbacks; +- a stable borrowed handle and exact `createdOn` answers after success; +- native failure with the exact result and a poisoned non-null output that is + not adopted or destroyed; +- native success with a null output; +- exact destruction of a successful owner; and +- independent owners that deliberately reuse one opaque handle value and each + destroy exactly once. + +The test also verifies that no hidden singleton, generation registry, +pipeline creation, export callback, or global dispatch path exists. +Owner-allocation failure was structurally reviewed through the nothrow +allocation and pre-call ordering. The stage did not add an allocator seam or +force a destructive real out-of-memory test. + +Independent implementation, test, gate, and adversarial reviews covered API +shape, failure-output ownership, exact create info, destruction, host +synchronization, CMake isolation, and test completeness. Review found no high- +or medium-severity issue. It found two low precision gaps, both resolved before +the platform gate: the result's copy, move, and destruction traits now have +explicit assertions, and the header limits its default internal-synchronization +claim to later pipeline-creation calls while retaining external requirements +for destruction and other host operations. + +**Gate result:** Accepted. No unresolved review finding remains. + +## Linux evidence + +The Release cache enabled tests and `LL_VULKAN_TONEMAP_TEST`, disabled both +compiler warning opt-outs, and built the viewer, production shader chain, +pipeline-cache archive, focused executable, and adjacent material targets. All +six cache cases and the exact 18 focused CTest routes passed. The 12 reflection, +eight artifact-delivery, and 57 benchmark-harness unit tests also passed. The +last suite exercised only Python harness logic and launched no benchmark. + +Formatting, diff, five-file source-boundary, path/privacy, backend-include, and +final source inspections passed. Neither the cache archive nor its executable +contains a direct `vkCreatePipelineCache` or `vkDestroyPipelineCache` symbol. +The focused executable has no Vulkan, MoltenVK, OpenGL, SDL, window-system, or +viewer dynamic dependency, and the viewer has no Vulkan or MoltenVK dependency. + +With the option disabled, all 25 enumerated opt-in targets were absent while +the neutral stale-artifact cleanup target remained. The Release viewer rebuilt +without Vulkan or MoltenVK linkage. The cache was restored to option-on, the +new targets returned, and the exact 18 focused routes passed again. This Linux +tree has packaging disabled, so it is not package evidence. + +**Linux result:** Passed. + +No Linux evidence may be described as a driver or cache-behavior test. The +focused path uses injected callbacks only. + +## macOS evidence + +A fresh user-owned disposable snapshot on macOS 26.6.2 with Xcode 26.6 and SDK +26.5 contained the Stage 28 commit plus exactly the five planned Stage 29 files. +It had exactly two synthetic commits and the intended five-file delta. The four +compiled source and build-input hashes matched the local candidate before +configuration, after both modes, and immediately before deletion. This decision +record was completed locally afterward and is not included in that frozen-input +claim. + +The option-on ReleaseOS gate used deployment target 11, `arm64;x86_64`, tests, +packaging, and warnings as errors, with renderer benchmarks, signing, crash +reporting, and proprietary components disabled. The full 204-target universal +build and package passed, as did the explicit viewer, package, production +shader, cache archive, and cache-test targets. All six cache cases, the exact 18 +focused CTest routes, 12 reflection tests, eight artifact-delivery tests, and 57 +benchmark-harness unit tests passed. + +The cache archive contains both architecture slices. Its focused executable is +native arm64 under existing project policy, has a valid signature, contains no +direct cache create or destroy symbol, and has no Vulkan, MoltenVK, OpenGL, SDL, +X11, Wayland, or viewer dependency. The universal app contained exactly two +production SPIR-V files, byte-identical to the build outputs and carrying the +canonical hashes below. All 367 packaged Mach-O files were readable and free of +Vulkan or MoltenVK linkage. + +After option-off reconfiguration, the full 168-target universal build and +package passed. All 25 opt-in targets were absent, the neutral cleanup target +remained, the app contained zero SPIR-V files, and all 367 Mach-O files remained +readable and dependency-clean. The exact disposable root was revalidated as +user-owned and non-symlinked, deleted, and independently verified absent. + +**macOS result:** Passed. + +The macOS gate did not launch the viewer or a sample, call a GPU or Vulkan +driver, create a real cache, use an account or region, compare pixels, run a +benchmark, or retain timing. + +## Production artifact hashes + +The gate confirmed that Stage 29 does not change the two accepted production +modules. + +| Module | Expected SHA-256 | Gate observation | +| --- | --- | --- | +| Vertex | `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095` | Exact match | +| Fragment | `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb` | Exact match | + +## Candidate source hashes + +These are the final hashes used by the disposable platform gate after the +source and build wiring were frozen. + +| File | SHA-256 | +| --- | --- | +| `indra/llrender/CMakeLists.txt` | `e5733266e7774094bd37a63b2c986ac236df28224e3eb6f7f50b4391bb508b54` | +| Pipeline-cache header | `4b0535752befb5af5006c46d1c97d0b7cb27a23191443ef6926d70b6001efa62` | +| Pipeline-cache implementation | `33a8513b7e72e82d2c7f998d72de34acbbc8ee2eb4c126fa01d7821aa0b28190` | +| Fake-dispatch test | `c5c2d4d778e9c86e78d8800fc64896e43251ee808670f1e314ac2726c626ee3e` | + +## Code size + +This table records the final Stage 29 diff, excluding this decision record. + +| Stage 29 change | Lines added | Lines removed | +| --- | ---: | ---: | +| Pipeline-cache backend header and implementation | 187 | 0 | +| Fake-dispatch pipeline-cache tests | 278 | 0 | +| Opt-in build wiring | 22 | 0 | +| Total | 487 | 0 | + +## Commit boundary + +The pipeline-cache header and implementation, its focused test, adjacent build +wiring, and this decision record form one Stage 29 commit whose parent is +`8ffed4368e0623dbc96cc18967885ab85074ed4f`. Unrelated user files are not part +of the boundary. + +## Explicit runtime gaps + +Stage 29 does not load Vulkan, obtain global or instance dispatch, create or own +an instance, debug messenger, surface, physical device, logical device, queue, +shader module, descriptor layout, pipeline layout, descriptor pool, render +pass, graphics pipeline, buffer, image, memory, view, sampler, framebuffer, +command pool, command buffer, semaphore, fence, or swapchain. + +It does not authenticate the instance API floor, the physical-to-logical-device +relationship, enabled features or extensions, or created queue families. It +does not select presentation support, enable the portability subset, or make +the Stage 28 material profile executable. + +It does not export, import, persist, invalidate, merge, populate, or measure a +pipeline cache. It defines no cache-key, variant-generation, cache-hit, +concurrent-creation, completion, or device-loss policy. It records no commands, +submits no work, presents no image, connects no viewer call site, selects no +runtime backend, compares no pixels, and collects no timing. + +The cache is one cold device-owned object, not a working Vulkan renderer. A +native material graphics pipeline remains dependent on the future authenticated +Stage 3 instance and logical-device generation, the Stage 28 capability +profile, same-device shader modules, pipeline layout and render pass, and this +cache. diff --git a/doc/performance/renderer-stage-30-decision.md b/doc/performance/renderer-stage-30-decision.md new file mode 100644 index 00000000000..be3124815e8 --- /dev/null +++ b/doc/performance/renderer-stage-30-decision.md @@ -0,0 +1,286 @@ +# Stage 30 Vulkan global-dispatch decision + +## Decision + +Begin master Stage 3 with one generic, backend-private global-dispatch +generation. The resolver accepts a caller-owned `vkGetInstanceProcAddr`, +resolves the Vulkan global command set with a null instance, queries the +loader's standard API version, and returns either a typed failure or an +immutable value containing the exact accepted function pointers and version. + +The existing offscreen material diagnostic is the first real consumer. Its +layer and extension enumeration, API qualification, instance creation, and +debug-messenger lookup use the accepted generation. The diagnostic retains its +existing native device, render, validation, and cleanup path. + +The generic archive and focused fake-dispatch test are available through a new +default-off `LL_VULKAN_RUNTIME_TEST` option. The existing +`LL_VULKAN_TONEMAP_TEST` option also builds them and connects the material +diagnostic to the archive. Neither option changes the default viewer backend. + +Stage 30 does not declare master Stage 2 complete. Production packet routing, +the direct-OpenGL allowlist, stable-resource pinning, reusable staging and +descriptor allocation, completion-backed native destruction, real cold and +warm cache behavior, and default CI coverage for the shader chain remain open. +Several of those exits need a real device, queue, pipeline, and completion +source, so the plan now interleaves their Stage 3 prerequisites instead of +adding another disconnected native-object transaction. + +## Why instance and surface ownership wait + +The current window abstraction has no Vulkan WSI request. Linux creates an SDL +OpenGL window and context, macOS installs an OpenGL view and context, and +Windows creates a WGL path. None returns an owned list of the Vulkan instance +extensions required by the selected native window or creates a Vulkan surface +for that exact window. + +An instance generation must enable that platform-derived extension set at +creation. Hardcoding X11 or Wayland extensions would bypass SDL's active video +driver; creating a macOS instance before a Metal-backed layer exists would not +produce a usable surface path; and the Windows route must bind the real +`HWND`. Creating an instance first and discovering its WSI requirements later +would make the supposedly immutable generation a dead end. + +The global command set and loader API version precede those platform choices. +They are also already duplicated by the native diagnostics. Stage 30 can +therefore establish a shared runtime dependency and prove it through a real +consumer without guessing the next platform contract. + +## Loader-lifetime boundary + +`VulkanGlobalDispatchGeneration` owns no operating-system library. The caller +owns the loader implementation behind `PFN_vkGetInstanceProcAddr` and keeps it +loaded while the generation or any object created through its functions can be +used. The value does not unload a library and does not claim that a loader was +found through any particular platform mechanism. + +Only its resolver can construct an accepted generation. The value exposes: + +- the exact caller-provided `PFN_vkGetInstanceProcAddr`; +- the resolved `PFN_vkCreateInstance`; +- the resolved `PFN_vkEnumerateInstanceExtensionProperties`; +- the resolved `PFN_vkEnumerateInstanceLayerProperties`; +- the resolved `PFN_vkEnumerateInstanceVersion`; and +- the exact accepted packed loader API version. + +The value has no public default constructor or mutator. Copy and move +construction preserve function identity; assignment is unavailable, so an +accepted generation cannot be rewritten after construction. + +It owns no instance, debug messenger, extension or layer storage, surface, +physical device, logical device, queue, allocator, or native object. It also +does not accept a portability flag. Portability enumeration is an instance +extension decision derived from actual extension enumeration, while the +portability subset remains a conditional device-extension obligation recorded +by Stage 28. + +## Exact global resolution + +The resolver first rejects a null `PFN_vkGetInstanceProcAddr` without making a +call. It then invokes the supplied resolver with `VK_NULL_HANDLE` and these +exact names in order: + +1. `vkCreateInstance`; +2. `vkEnumerateInstanceExtensionProperties`; +3. `vkEnumerateInstanceLayerProperties`; and +4. `vkEnumerateInstanceVersion`. + +The first three functions are required Vulkan 1.0 global commands. Resolution +stops at the first missing required command and identifies it in the typed +error. The version query is optional in Vulkan 1.0. If it is absent, the +resolver applies Vulkan's defined 1.0 loader fallback and rejects that version +against this renderer's established Vulkan 1.1 floor. + +When `vkEnumerateInstanceVersion` exists, the resolver calls it exactly once. +A failed `VkResult` is preserved and the output value is ignored, including if +a faulty or fake implementation wrote poison into it. A successful query with +a nonzero API variant is rejected before the standard version comparison. A +standard version below Vulkan 1.1 is also rejected. Vulkan 1.1 and newer +standard versions are retained exactly. + +The service does not enumerate instance extensions or layers. Those APIs can +return variable data and are policy inputs to the later instance transaction. +Stage 30 authenticates their function identities, not any extension, layer, +driver, device, or WSI capability. + +## Typed failures + +`VulkanGlobalDispatchResolutionError` distinguishes: + +- a null `vkGetInstanceProcAddr`; +- a missing required global command, with exact command identity; +- a failed version query, with its exact `VkResult`; +- a successful nonstandard API variant; and +- a standard loader API version below Vulkan 1.1. + +The error carries the relevant command and a defined available version only +when those facts exist. It never promotes the output from a failed version +query into evidence. + +## Material diagnostic integration + +`VulkanMaterialRun::createInstance()` resolves one generation before it +enumerates layers or extensions. The accepted value is retained for the run. +The diagnostic then uses it for both enumeration functions and +`vkCreateInstance`, eliminating those direct global calls and the duplicate +loader-version query. + +The same retained `vkGetInstanceProcAddr` resolves debug-messenger creation and +destruction with the valid instance. The diagnostic continues to call its +existing directly linked instance- and device-level Vulkan functions. Moving +those calls behind an owned instance/device dispatch generation belongs to +later stages and is not implied here. + +The integration preserves the diagnostic's established validation layer, +debug-utils extension, conditional portability-enumeration extension and +flag, physical-device selection, conditional portability-subset device +extension, render fixture, rejection cases, submission, artifact format, and +cleanup ordering. + +## Build boundary + +`LL_VULKAN_RUNTIME_TEST` defaults off and requires tests. It locates Vulkan +headers, builds the generic archive and focused integration executable with +`VK_NO_PROTOTYPES`, and does not link either target to a Vulkan loader. It is +not restricted to Linux or macOS, so the fake suite has a Windows compilation +path independent of the native diagnostics. + +`LL_VULKAN_TONEMAP_TEST` remains the stronger Linux/macOS-only diagnostic +boundary. It implies the generic targets, retains its existing shader tools, +validation-layer requirements, native loader linkage, and macOS MoltenVK +packaging, and links only `llvulkanmaterial` to the new archive. + +The new archive does not enter `llrender`, `llwindow`, the viewer, or the +neutral renderer contract. Both options off must remove every Stage 30 target +and preserve the viewer's existing Vulkan-free dependency boundary. + +## Focused tests and review + +The fake-dispatch suite covers construction traits, a null resolver, each +missing required global, the Vulkan 1.0 fallback, a failed version query with +poisoned output, nonstandard variants, versions below the renderer floor, +exact Vulkan 1.1 and newer versions, pointer identity, and exact lookup order +and counts. All six focused cases pass on Linux and macOS. + +Independent API and build-boundary reviews found and resolved two medium and +three low issues before the final gates. Failed instance and debug-messenger +creation now publish handles only after success and reject success with a null +handle, so poisoned failure outputs cannot enter cleanup. Unrelated formatting +churn was removed. Nonstandard API variants receive a distinct diagnosis, the +new option describes its runtime-dispatch scope, and rejection tests assert +exact lookup counts. Final review found no unresolved high, medium, or low +issue. + +## Linux evidence + +Warnings-as-errors Release builds pass for the viewer, production shader +chain, generic dispatch archive and fake test, and native material diagnostic. +The runtime-only option builds the generic archive, fake test, and viewer +without the native diagnostic. With both Vulkan options off, the viewer still +builds and every opt-in target is absent. + +All 19 focused renderer CTest routes pass, as do the 12 reflection tests, eight +artifact-delivery tests, and 57 benchmark-harness unit tests. A native RADV +material run passes all 832 component checks and 39 rejection cases with one +recording, one submission, zero validation messages, conditional portability +enumeration enabled, and no retained artifact. No benchmark or timing was run +or retained. + +The generic archive and fake executable have no direct Vulkan symbol or loader +dependency. Among the migrated globals, the native diagnostic imports only +`vkGetInstanceProcAddr`; the viewer imports no Vulkan symbol and has no Vulkan +or MoltenVK dependency. The remaining diagnostic instance- and device-level +imports are explicitly deferred. + +## macOS evidence + +A warnings-as-errors universal Release build and package pass with both Vulkan +options enabled. The generic archive contains arm64 and x86_64 slices; the +focused executable follows the project's arm64 test policy, verifies its code +signature, and has no Vulkan or MoltenVK dependency. The same 19 focused CTest +routes and the 12, eight, and 57 Python test groups pass. + +An arm64 MoltenVK material run passes all 832 component checks and 39 rejection +cases with one recording, one submission, zero validation messages, +portability enumeration enabled, portability subset enabled, and no retained +artifact. The enabled package contains exactly the two canonical production +SPIR-V files. All 367 packaged Mach-O files are readable and none depends on +Vulkan or MoltenVK. + +Reconfiguring the same disposable source with both Vulkan options disabled +reduces the build dependency graph from 206 to 168 and removes all 38 opt-in +Xcode targets. The universal build and package pass again, the package contains +zero SPIR-V files, and the same 367 Mach-O files remain readable and free of +Vulkan or MoltenVK dependencies. The six compiled Stage 30 inputs still match +the local candidate byte for byte. No viewer, account, region, benchmark, or +timing path was used, and the disposable test tree was removed. + +## Windows evidence boundary + +The generic option and target graph are designed to compile and run the fake +suite on Windows without importing `vulkan-1.dll`. No Windows host is available +in this stage. Stage 30 therefore makes no claim about an installed Windows +loader, driver, instance, window surface, presentation queue, or runtime +behavior. + +## Production artifact hashes + +The enabled Linux and macOS builds reproduce the canonical production shader +artifacts: + +- vertex: 8,068 bytes, + `b5750a179572b2fc3545c7472a28cac963351f7e7bc44df38190bb8961894095`; +- fragment: 7,824 bytes, + `850d765cb1a6cd31dfbe16f94cedd89ab8306eca771cf833fe47c441b9c743fb`. + +## Candidate source hashes + +The final compiled and build-wiring inputs have these SHA-256 identities: + +- `indra/cmake/Variables.cmake`: + `d3fd8713e96badf5c049fdaa0ef1ba1f27c4d2e0d2f8bcf1281f409626fd0062`; +- `indra/llrender/CMakeLists.txt`: + `097b8aa16590a036d9be7b42f722a0a83a64106a2b7ed9b6bd20f7d72177ab73`; +- `indra/llrender/vulkan/llvulkanmaterial_main.cpp`: + `850917c6586ac63b5712ad1f6d5ce9cabd2447b810d2db104807e4b1d7ed9e54`; +- `indra/llrender/tests/llrendervulkanglobaldispatch_test.cpp`: + `4998ddd01abe85c5601f3cfff5936c193e2f817212c30afd700230503f49f21f`; +- `indra/llrender/vulkan/llrendervulkanglobaldispatch.cpp`: + `bc0a71877dae48f555735c1d44647fb3b6797f65e63fed2de5bcab6592383e05`; +- `indra/llrender/vulkan/llrendervulkanglobaldispatch.h`: + `b6d6e80f65bccec743a6529906408e26fca8ee3ae7013a26b8bfa25990013b91`. + +The decision record is excluded because including its own digest would be +self-referential. + +## Code size + +The final patch adds 945 lines and removes 27 lines across seven files: 659 +additions and 27 deletions are compiled or build-wiring inputs, while 286 +additions are this decision record. + +## Commit boundary + +The generic dispatch header and implementation, its focused test, the material +diagnostic integration, adjacent build option and wiring, and this decision +record form one Stage 30 commit whose parent is +`8dc04bb34d8a9ab934a3d7932afbacc82184fe5e`. The rolling PostPlan and unrelated +user files are outside the commit boundary. + +## Explicit runtime gaps + +Stage 30 does not dynamically load or unload Vulkan. It does not own an +instance or debug messenger, define typed OpenGL/Vulkan/headless window +selection, request platform WSI extensions, create a window or surface, select +graphics and presentation queues, or create a logical device. + +It does not connect the Stage 28 capability profile to a device-create +transaction or create the Stage 29 cache on a real owned device. It creates no +new shader module, layout, descriptor resource, render pass, pipeline, buffer, +image, memory, sampler, framebuffer, command pool, command buffer, semaphore, +fence, or swapchain. + +It adds no viewer backend route, pass allowlist, direct-OpenGL lint, resource +resolver, staging ring, descriptor arena, completion-backed native retirement, +cache persistence, warm-cache test, login, region, pixel comparison, timing, +benchmark, parity result, performance conclusion, or default-backend claim. diff --git a/doc/performance/renderer-stage-4-decision.md b/doc/performance/renderer-stage-4-decision.md new file mode 100644 index 00000000000..efd8398a231 --- /dev/null +++ b/doc/performance/renderer-stage-4-decision.md @@ -0,0 +1,86 @@ +# Renderer Stage 4 decision + +## Decision + +The schema-3 scene-validity contract is ready for hardware execution. Stage 4 makes an ambiguous workload fail before it can enter a performance report. It produces no retained timing evidence and makes no claim about OpenGL, Vulkan, Zink, or Apple Silicon performance. + +Reanalysis in this stage splits the former combined Mac baseline stage into two committable stages. Stage 4 lands and verifies the validity definition. Stage 5 will collect the five-run Apple Silicon baseline from a clean build of that committed definition. Earlier documents combined those tasks; this decision supersedes that commit boundary. + +## Contract delivered + +Schema version 3 adds a declarative validity policy to every scenario and a privacy-safe validity record to every result. The collector and runner fail closed on: + +- requested placement, teleport, and progress state; +- accumulated camera translation and rotation, camera animation, and avatar travel; +- foreground focus for the capture window; +- modal or first-use UI, alerts, hints, progress UI, and scenario-specific floater state; +- texture-fetch, HTTP, texture-creation, fast-cache, upload, and unresolved mesh work; +- self-avatar completion, visible-avatar drift, active-object drift, and new objects; +- circuit health and simulator ping; and +- typed operator assertions for power source, low-power mode, thermal state, material scene events, intended UI, and intended camera. + +Comparable results must also have identical manifest hashes, workload slugs, validity policies, operator assertions, relative camera-view hashes, and stable population counts. The runner recalculates every gate from the retained observations instead of trusting the collector's booleans. + +## Privacy boundary + +The viewer compares the requested destination against the live region and position but exports only a boolean. The collector hashes a rounded camera view relative to the avatar, then discards the raw view. Accounts, credentials, SLURLs, regions, parcels, absolute positions, camera origins and orientations, host identifiers, and machine identifiers are removed recursively. Printed launch commands redact all login fields and the SLURL. + +## Provenance and policy + +The placement rule reuses the viewer's exact-region and 2 m X/Y start-location tolerance. Camera motion reuses the scene-loading monitor's accumulated 0.1 m translation and 0.05 rad rotation limits. The 15-second window comes from the mesh subsystem's no-progress horizon. Focus and zero population drift follow existing performance and benchmark behavior. + +Their use as benchmark acceptance gates is a project decision. The composite all-zero asset rule, zero avatar travel, zero new objects, capture-wide 600 ms ping limit, typed operator vocabulary, five repeats, and 30-second warm-up are conservative benchmark policy rather than an upstream performance standard. In particular, the 15-second precedent is mesh-specific and the existing 600 ms lag-meter value is based on period-mean ping. A strict false rejection is acceptable; an ambiguous timing sample is not. + +Power and thermal fields are operator assertions, not automatic hardware telemetry. A valid run requires a known and repeatable power source, low-power mode off, and a non-throttled thermal state. AC power is not universally required. All assertions must match across repeats. + +## Evidence + +- All focused Python benchmark tests pass, including valid manifests and fixtures, placement, focus, asset, population, UI, tamper, comparison, and privacy cases. +- Both JSON schemas accept all checked-in manifests and the synthetic schema-3 result fixture. +- The changed benchmark-enabled Linux C++ units compile with the new viewer facts and mutex-protected mesh queue counts. The stats listener also compiles with `LL_RENDER_BENCHMARK` disabled. +- The existing benchmark display and directory integration tests pass. +- A full Linux link was not re-proven in this environment. The build progressed through the changed mesh unit, then GCC 15 stopped on an unrelated pre-existing `-Werror=array-bounds` diagnostic in `llpaneloutfitedit.cpp`. The changed stats unit was compiled separately after that failure. +- No live result, credential, SLURL, private scene identifier, or timing measurement is retained in this commit. + +The public source trail is consistent with the validity-first boundary: [PR #6032](https://github.com/secondlife/viewer/pull/6032#issuecomment-5298969752) records an Apple Silicon FPS comparison invalidated by lost focus; [issue #4274](https://github.com/secondlife/viewer/issues/4274#issuecomment-2985035642) calls for scene texture-load metrics before A/B testing; [issues #5742](https://github.com/secondlife/viewer/issues/5742) and [#5663](https://github.com/secondlife/viewer/issues/5663) request live scene and camera facts for automation; and [issue #5368](https://github.com/secondlife/viewer/issues/5368#issuecomment-4361798311) supports standardized performance export and a 120-second observation window. None mandates this stage split, schema number, or the project-specific acceptance thresholds. + +## Reanalysis + +The earlier display split and this scene split follow the same evidence boundary: a result cannot honestly claim proof that was added after it was collected. Stage 1 already showed that plausible-looking runs with different destination, UI, cache, and asset work cannot be repaired by reporting logic. Public viewer history also records an Apple Silicon FPS comparison invalidated by a brief focus loss and requests scene-level asset and camera facts before automation. + +This stage does not prove that every conservative gate will pass in the chosen live scene. That is now the first question Stage 5 must answer. If the controlled scene cannot satisfy the contract without weakening it after seeing timings, the baseline remains blocked and the rejected gate facts become the next diagnostic input. + +## Stage 5: controlled Apple Silicon native OpenGL baseline + +### Objective + +Collect one privacy-safe, five-repeat native OpenGL baseline on Apple Silicon using a clean ReleaseOS build of the committed schema-3 contract. Characterize the current path only. Do not compare hardware classes, add renderer code, or retain raw private artifacts in Git. + +### Inputs and preflight + +1. Start from a clean checkout containing the Stage 4 commit. Record the commit and confirm the result reports `git_dirty: false`. +2. Build the universal macOS viewer with `LL_RENDER_BENCHMARK=ON`, `LL_TESTS=ON`, signing and crash reporting off, and the existing ReleaseOS settings. +3. Run the Python benchmark suite and focused display/macOS integration tests. +4. Use `steady-warm-v1.json` without editing its display, graphics, or validity fields. +5. Keep the account, credential file, controlled SLURL, output directory, and visual reference outside the repository. Use a stable, non-identifying workload slug. +6. Record truthful operator assertions immediately before the batch. Use the same power source for every repeat, keep low-power mode off, and stop if the machine is throttled or the assertions change. + +### Capture + +1. Launch native Apple OpenGL with `--expect-gpu-substring Apple` and the typed schema-3 operator arguments. +2. Let the runner perform one full unmeasured warm-cache prime. +3. Collect five measured repeats. Each repeat uses 30 seconds of warm-up and 120 seconds of capture. +4. Do not touch the camera, avatar, window focus, or controlled UI after the prime begins. +5. Treat every nonzero exit or failed gate as a rejected run. Preserve only a privacy-safe rejection count and failed-gate names; do not include rejected timing in a report. +6. If five valid repeats cannot be obtained without changing the contract, stop and report the blocking gates. Do not tune thresholds from observed frame times. + +### Analysis and exit evidence + +1. Validate all five raw results and generate the standard report without `--allow-mismatch`. +2. Confirm equal source, manifest, settings, display geometry, workload, operator assertions, relative camera view, population, and instrumentation across repeats. +3. Report median, p95, p99, worst frame, 1 percent low FPS, run-to-run p95 range, dominant CPU phases, resource-counter deltas, and rejected-run reasons. +4. Keep raw results and visual material private. Check in only a sanitized aggregate and the Stage 5 decision. +5. Make no performance conclusion beyond the existing threshold: a delta must exceed both 1 ms and three times run-to-run p95 range. A single baseline has no backend or hardware delta. +6. End Stage 5 by selecting exactly one next investigation from the observed dominant cost, or by recording that the controlled baseline is stable but not diagnostic. + +Stage 6, the matched older-hardware comparison, remains locked until Stage 5 is complete and replanned. diff --git a/doc/performance/renderer-stage-5-aggregate.json b/doc/performance/renderer-stage-5-aggregate.json new file mode 100644 index 00000000000..2e31fa1addb --- /dev/null +++ b/doc/performance/renderer-stage-5-aggregate.json @@ -0,0 +1,50 @@ +{ + "stage": 5, + "status": "blocked", + "source_commit": "fa875954c55780153510975a271023ab0e66e782", + "manifest": "steady-warm-v1", + "manifest_schema_version": 3, + "backend": "native-gl", + "hardware_class": "Apple Silicon", + "build": "ReleaseOS universal", + "workload_id": "controlled-steady-scene", + "planned_measured_repeats": 5, + "valid_measured_repeats": 0, + "retained_timing": false, + "operator_assertions": { + "power_source": "ac", + "low_power_mode": "off", + "thermal_state": "nominal", + "scene_events": "none", + "ui_state": "approved", + "camera_state": "approved" + }, + "rejected_attempts": [ + { + "attempt": 1, + "phase": "warm-prime", + "failed_gates": [ + "placement", + "avatar", + "assets" + ] + }, + { + "attempt": 2, + "phase": "warm-prime", + "failed_gates": [ + "placement", + "focus", + "avatar", + "assets" + ] + } + ], + "failed_gate_counts": { + "placement": 2, + "focus": 1, + "avatar": 2, + "assets": 2 + }, + "next_stage": "isolated-asset-readiness-gate-investigation" +} diff --git a/doc/performance/renderer-stage-5-decision.md b/doc/performance/renderer-stage-5-decision.md new file mode 100644 index 00000000000..f5b6a4f8949 --- /dev/null +++ b/doc/performance/renderer-stage-5-decision.md @@ -0,0 +1,59 @@ +# Renderer Stage 5 decision + +## Decision + +The controlled Apple Silicon native OpenGL baseline is blocked. Two full warm primes failed the committed schema-3 scene contract before any measured repeat began. Stage 5 retains no timing and makes no performance claim. + +The first prime failed the placement, avatar, and assets gates. A second prime used the equivalent canonical Second Life URI to rule out the supplied web-map URL form. It failed placement, focus, avatar, and assets. The asset and self-avatar failures repeated while the viewer reported unsuccessful asset-cache writes and an incomplete self avatar. + +The contract did its job. Loosening a gate after seeing these failures would turn a known invalid scene into a plausible-looking baseline. + +## Build and test evidence + +- Source was clean at `fa875954c55780153510975a271023ab0e66e782`, the committed Stage 4 contract. +- The checked-in `steady-warm-v1.json` manifest was not changed. +- All 34 Python benchmark tests passed. +- Both JSON schemas accepted all six manifests and the schema-3 fixture. +- The display and macOS directory integration tests passed. +- A clean benchmark-enabled universal ReleaseOS build compiled and packaged the Intel and Apple Silicon app. +- Both attempts used native OpenGL on the expected Apple GPU class. +- Power remained on AC with low-power mode off. The host reported no thermal or performance warning before or after the attempts. +- The dry run printed neither login identity nor destination. + +The first packaging attempt exposed a stale generated `AUTOBUILD_EXECUTABLE` cache entry from an earlier disposable environment. Reconfiguring the ignored build tree with the current disposable Autobuild path fixed packaging. This did not change source or benchmark behavior. + +## Sanitized rejection aggregate + +| Gate | Rejected primes | +| --- | ---: | +| Placement | 2 | +| Focus | 1 | +| Avatar | 2 | +| Assets | 2 | + +The runner produced zero measured artifacts. Rejected frame data, live logs, credentials, destination, isolated profiles, and visual material stay outside Git. The checked-in aggregate records gate names and counts only. + +## Reanalysis + +The five-repeat baseline cannot start from this account and isolated state. The repeated asset-cache write failures and incomplete self avatar make the asset gate the first dependency to investigate. Placement and focus also remain unresolved, but neither can make an asset-invalid prime usable. + +The matched older-hardware control is no longer the next stage. It remains locked until one clean prime passes the existing contract. Renderer tracing, OpenGL changes, Vulkan work, threshold tuning, and hardware comparison also remain locked. + +## Stage 6: isolated asset-readiness gate investigation + +### Objective + +Determine why a fresh isolated macOS prime cannot reach the existing all-zero asset rule or complete the self avatar. Do not collect or report performance timing. + +### Scope + +1. Reproduce one short non-timing launch from the committed schema-3 manifest and the same disposable profile rules. +2. Record privacy-safe facts for cache directory creation, VFS open and write status, pending asset requests, texture and mesh queues, and required self-avatar parts. +3. Distinguish a cache-path or purge-order failure from missing account wearables or server delivery failure. +4. Check whether the isolated warm cache survives long enough to serve a second validation launch. +5. Keep the placement, focus, avatar, and asset thresholds unchanged. +6. Do not alter the account outfit unless a separate operator decision authorizes that persistent change. + +### Exit evidence + +Stage 6 ends with one root-cause statement and one bounded correction or operator prerequisite. A validation prime must pass the unchanged assets and avatar gates before the baseline can return to the roadmap. Stage 6 must not execute the five measured repeats. diff --git a/doc/performance/renderer-stage-7-decision.md b/doc/performance/renderer-stage-7-decision.md new file mode 100644 index 00000000000..ece5aa434fa --- /dev/null +++ b/doc/performance/renderer-stage-7-decision.md @@ -0,0 +1,156 @@ +# Renderer Stage 7 decision + +## Decision + +The account and wearable attribution stage is complete, but the Apple Silicon +native OpenGL baseline remains blocked. Two full prime-only launches produced +the same privacy-safe classification: +`required-link-missing-or-unresolved`. + +The account's Current Outfit Folder (COF) existed and was category-complete in +both launches. The viewer could not resolve a required shape, skin, hair, or +eyes link from that folder, and none of those four required wearables was +delivered to the avatar. Cache reuse passed. No measured repeat ran, no frame +timing was retained, and this stage makes no OpenGL, Vulkan, Zink, or hardware +performance claim. + +## Contract delivered + +- A benchmark-only viewer operation returns scene readiness and appearance + facts in one main-thread response. The conservative avatar gate and its + attribution therefore describe the same snapshot. +- The diagnostic path is requested only by prime-only readiness runs. Normal + warm primes and measured runs retain the existing performance-data path. +- The appearance record contains fixed booleans for avatar validity, COF + presence and completeness, COF-change context, four required-link states, + four required-wearable delivery states, and final avatar readiness. +- A pure C++ classifier and a fail-closed Python projection enforce the fixed + precedence and reject malformed, contradictory, or non-boolean facts. +- Appearance is optional in schema-3 observations. It changes no scene gate, + threshold, policy hash, manifest hash, comparison field, or timing summary. +- Inventory identifiers, item names, account data, destinations, raw log text, + and filesystem paths cannot enter the retained appearance record. + +The fixed classifications are `avatar-unavailable`, `cof-incomplete`, +`required-link-missing-or-unresolved`, +`wearable-delivery-pending-or-failed`, `avatar-later-blocker`, and `ready`. +`unknown` is reserved for unavailable or invalid attribution. + +## Verification + +- All 57 Python benchmark tests passed. +- Draft 2020-12 validation passed for all six manifests, the schema-3 fixture, + and a result containing the optional appearance record. +- The appearance, display, and cache-migration C++ integration tests passed + with 6, 2, and 2 focused cases respectively. +- The benchmark-enabled universal Release build completed and contained both + `arm64` and `x86_64` slices. +- Exactly two native Apple OpenGL prime-only launches ran on AC power with low + power mode off and no reported thermal or performance warning. +- The viewer did not run after the sequence, and private credentials, logs, + results, readiness reports, isolated profiles, and temporary state were + removed. + +## Sanitized two-prime evidence + +| Fact | Prime 1 | Prime 2 | +| --- | --- | --- | +| Cache state | Writable and ready | Writable, sentinel reused, no fallback | +| Failed scene gates | Placement, avatar, assets, population | Placement, avatar, assets, population | +| Avatar object valid | Yes | Yes | +| COF present and complete | Yes | Yes | +| COF change in progress | Yes | Yes | +| Shape, skin, hair, and eyes links resolved | No for all four | No for all four | +| Shape, skin, hair, and eyes wearables delivered | No for all four | No for all four | +| Self avatar loaded | No | No | +| Appearance classification | `required-link-missing-or-unresolved` | `required-link-missing-or-unresolved` | +| Asset settlement window completed | Yes | Yes | +| Asset queues settled | No | No | +| Measurement | None | None | + +The first prime observed no unresolved mesh work, while texture fetch and HTTP +work remained active. The second prime observed unresolved mesh work and more +texture activity. These are rejection facts, not comparable performance data. +They do not identify why the public scene remained unsettled. + +## Reanalysis + +The repeated account classification is stronger than the earlier log category. +It establishes that the COF itself was available and complete as an inventory +category, but the local inventory could not resolve any of its four required +body-part links during either full prime. + +It does not establish whether each required link is absent from the COF or is +present with an unavailable target. Once a link target is unavailable locally, +the stable public inventory interface cannot reveal the target's intended +wearable type. The combined classification preserves that limit instead of +guessing. Likewise, four undelivered wearables are consistent with unresolved +links but do not prove a terminal server-side delivery failure. +`cof_change_in_progress` is supporting context, not causal proof. + +The warm-cache lifecycle remains closed: the second launch reused the requested +writable asset cache and its sentinel without a known cache failure or fallback +asset root. Placement, population, and active asset work remain independent +scene blockers, but none can make the unresolved appearance state valid. + +The original renderer-modernization plan therefore remains in master Stage 0. +Baseline timing, hardware comparison, tracing, OpenGL changes, Zink experiments, +renderer abstraction, and Vulkan implementation stay locked. + +## Stage 8: establish a resolvable account outfit + +### Objective + +Satisfy or conclusively record the account prerequisite behind the Stage 7 +classification. Verify that the COF contains resolvable links to shape, skin, +hair, and eyes before attempting another baseline. This is one committable +account-readiness stage inside master Stage 0. It collects no performance +timing and changes no renderer code or validity gate. + +### Authorization boundary + +Inspecting the account and rerunning the prime-only diagnostic are read-only. +Repairing the COF, wearing a replacement outfit, or changing any inventory link +is persistent account mutation and requires separate explicit authorization. +Without that authorization, record the operator prerequisite and stop before +changing the outfit or launching another validation sequence. + +### Execution + +1. Start from a clean checkout containing the Stage 7 commit. Re-run the focused + tests only if the executable or environment has changed. +2. Using private runtime state, inspect the demo account's COF in the viewer and + verify whether it visibly contains one link for each required body part. Do + not retain item names, identifiers, screenshots, raw inventory output, or + account details. +3. If a link is missing or broken, stop and report that exact prerequisite. With + separate mutation authorization only, repair the four links or wear a known + complete default outfit without changing graphics, destination, manifest, or + benchmark policy. +4. After the operator verifies a resolvable COF, run exactly two prime-only warm + launches against one disposable cache. Hold account, outfit, destination, + manifest, build, and operator assertions fixed. +5. Require all four `required_links_resolved` facts to be true in both launches. + Record whether all four required wearables are delivered, whether the avatar + becomes loaded, cache reuse, and every unchanged failed scene gate. +6. Retain zero measured repeats and no timing. Remove credentials, logs, results, + readiness output, isolated profiles, and temporary state. +7. Commit only the sanitized decision and any narrowly required diagnostic fix, + reanalyze the next dependency, update the rolling plan, and stop. + +### Exit gates + +- Any account mutation was separately and explicitly authorized. +- The operator check chooses missing/broken COF links or confirms four resolvable + required links without publishing private inventory data. +- A validation sequence, if eligible, uses exactly two fixed-state prime-only + launches and the unchanged schema-3 gates. +- Cache reuse remains ready with no fallback or known write failure. +- Valid measured repeats remain zero and retained timing remains false. +- The repository and Mac checkout end clean and contain no private artifact. + +If the links resolve but required wearables remain undelivered, the following +stage investigates delivery. If links and wearables are ready but the avatar is +not, it investigates the later appearance blocker. Only an avatar-ready, +asset-ready prime can unlock controlled-scene preparation and the five-repeat +baseline. diff --git a/doc/performance/renderer-stage-9-decision.md b/doc/performance/renderer-stage-9-decision.md new file mode 100644 index 00000000000..52714ae5936 --- /dev/null +++ b/doc/performance/renderer-stage-9-decision.md @@ -0,0 +1,82 @@ +# Stage 9 renderer contract decision + +## Decision + +Accept the backend-neutral renderer contract as the Stage 1A foundation. It is sufficient to record and validate the selected full-screen, indexed-material, and streaming-texture slices without importing OpenGL or Vulkan vocabulary. + +This does not complete master Stage 1. No OpenGL executor, Vulkan executor, rendered-output comparison, performance result, or backend recommendation exists yet. Current rendering output is unchanged. + +## Boundary + +The contract is an immutable work and resource-reference stream. Viewer policy resolves scene objects, settings, materials, and decoded images above it. Backend-neutral registries own persistent resource contents and shader implementations. Executors resolve typed handles below it and own API objects, synchronization primitives, state translation, and deferred destruction. + +The frame stream therefore describes resources used by a frame, not a general resource-creation protocol. A later replay harness can seed OpenGL and Vulkan registries from the same CPU fixture. The texture upload remains in the stream because streaming publication and old-generation retirement are one of the three selected slices. + +`llrendercontract` is a separate standard C++ target. Its source and public header contain no graphics API or window-system include, symbol, handle, enum, or link dependency. Existing `llrender` and `llrenderheadless` link to it in one direction. + +## Representative traces + +### Tonemap full-screen pass + +The boundary is immediately above `LLPipeline::tonemap()`. The fixture records the RGBA16F destination, RGBA16F scene input, 1x1 R16F exposure input, samplers, a stable shader-program key, the 16-byte parameter block, fixed pipeline state, viewport, scissor, image accesses, and the real three-position screen-triangle buffer with its 16-byte stride. + +GLSL filenames, reflected uniform locations, texture units, FBOs, and the `LLGLSLShader`, `LLRenderTarget`, and `LLVertexBuffer` objects stay below the boundary. + +### Indexed material draw + +The boundary is after viewer draw preparation and before the deferred material pass issues API calls. The fixture snapshots the three material image views and mip ranges, seven planar vertex bindings, U16 index range, parameters, three G-buffer attachments, depth attachment, explicit access states, and pipeline identity. Position and normal strides match the viewer's 16-byte planar layout. + +`LLDrawInfo`, viewer material objects, mutable transform pointers, GL buffer names, and draw-pool state stay outside the contract. + +### Streaming texture upload + +The boundary is after decoded-image admission and before `LLImageGL` creates or publishes a physical texture generation. The fixture owns the upload bytes and records revision, physical and logical extents, resident discard, row pitch and origin, format, generated mip range, transfer and final states, and the new image generation. The old generation remains declared until its completion frame and is then released. + +Fetch priority, cache and asset identity, `LLImageRaw`, GL names, shared-context fences, viewer callbacks, and the current three-frame GL deletion queue remain outside the contract. + +## Validation + +The null validator checks: + +- typed generational resource identity, declaration uniqueness, and lifetime; +- resource, enum, binding, byte-range, vertex, index, subresource, viewport, scissor, and upload bounds; +- stable shader keys, pipeline-owned vertex layouts, exact parameter sizes, and attachment compatibility; +- ordered pass dependencies and complete buffer and image access declarations; +- per-generation and per-subresource image-state continuity, including generated mip ranges; +- attachment load/store semantics, including invalidation after `DontCare` stores; +- immutable upload ownership, row pitch, logical-discard relation, revision uniqueness, and mip policy; +- completion-safe retirement and stale-generation rejection. + +Fifteen focused tests cover the three valid fixtures and failures for missing access, invalid dependencies and enums, stale generations, draw and upload bounds, parameter size, mip-range coverage, index alignment, attachment discard, and early release. + +## Measured surface + +The stage adds 2,212 lines before this decision record: + +| Surface | Size | +| --- | ---: | +| Public contract header | 530 lines | +| Null-validator implementation | 1,182 lines | +| Focused fixtures and tests | 478 lines | +| Build wiring | 22 lines | + +The public vocabulary contains 20 enums and 40 structs, including four distinct generational handle types. This is a meaningful interface, so later stages should migrate one real slice at a time and resist adding fields until a concrete executor or pass requires them. + +## Verification + +- Linux built the independent contract target and passed all 15 tests. +- GCC 15 compiled the GL-free implementation with C++20, `-Wall -Wextra -Wpedantic -Werror`. +- Source inspection found no OpenGL or Vulkan include or symbol in the contract or fixtures. +- macOS built the contract archive for both `arm64` and `x86_64`; `lipo` reported both architectures. +- The host-architecture macOS integration executable passed all 15 tests. Test executables are intentionally host-only in this build system. +- The benchmark-enabled universal Release viewer linked against the contract for both architectures. + +The local full `llrender` target was not used as evidence because this checkout's host environment lacks the GLX development header and has an incompatible cached precompiled-header fortify setting. Those failures predate and do not involve the GL-free target. The full macOS universal link covers integration with `llrender`. + +No viewer was launched, no account or profile was opened, and no benchmark timing was collected. + +## Reanalysis and next stage + +The contract is large enough that adding a second executor for all three slices at once would hide boundary mistakes and produce a poor rollback unit. The smallest useful next commit is Stage 1B: record the existing tonemap inputs through a pure builder and execute that one packet through the current OpenGL objects while preserving the existing image output. + +Stage 1B must establish the backend-neutral registry seam for the screen triangle, images, samplers, and tonemap program; add an OpenGL executor only for the vocabulary used by that packet; compare the legacy and contract-driven offscreen output from fixed inputs; and retain an immediate legacy fallback. It must not add Vulkan, convert the material or upload paths, or change the production default. diff --git a/doc/vulkan-migration-todo-plan.html b/doc/vulkan-migration-todo-plan.html new file mode 100644 index 00000000000..b2510ceb67d --- /dev/null +++ b/doc/vulkan-migration-todo-plan.html @@ -0,0 +1,814 @@ + + + + + + + Second Life renderer modernization todo plan + + + +
+
+

Rolling renderer plan

+

One stage. One commit. Then replan.

+

Stages 1 through 4 now provide a reproducible, isolated, display-normalized, fail-closed benchmark contract. Stage 5 has the only detailed checklist: collect the first controlled Apple Silicon native OpenGL baseline from that committed contract.

+
+ Second Life viewer + Plan v3 + Repository snapshot through Stage 4 + 26 August 2026 +
+
+
+ + + +
+
+
+

Execution rules

+
+
+ Current stage + Stage 5 +
+
+ Plan state +

Stages 1 through 4 are complete. Stage 5 has the only active implementation checklist. Stage 6 and all renderer implementation stages remain locked and deliberately coarse.

+
+
+ +
+ No parallel stages. A stage begins only after the previous stage passes its checks, updates this plan with actual results, expands exactly one next stage, and lands as a reviewable commit. If a stage grows too large for one commit, split it during the preceding reanalysis. Do not discover that halfway through implementation. +
+ +
+
01

Execute

Work only from the detailed checklist for the active stage. Preserve unrelated changes.

+
02

Verify

Run the stage-specific tests and collect the evidence named in its exit gates.

+
03

Reanalyze

Compare the result with the remaining roadmap. Remove assumptions that the evidence disproves.

+
04

Commit

Update this same plan, detail the next stage, then commit the stage as one coherent change.

+
+
+
+ +
+
+

Sequential roadmap

+

The evidence work is now split into reviewable commits. Only Stage 5 is detailed. Every later boundary remains provisional until the preceding stage updates this plan.

+ +
+
+
Stage 1
+

Build the benchmark suite

Low-overhead phases, resources, manifests, runner, reporter, privacy, and native GL/Zink launch paths.

+

The first live matrix was invalid because destination, UI, cache, and asset work differed. No performance conclusion survived.

+
Commit771b86ba5eOutcomeMeasure first; require a controlled workload
+
+ +
+
Stage 2
+

Isolate macOS benchmark state

Disposable profile, cache, settings, and first-use UI controls.

+

Benchmark launches no longer read or mutate the normal viewer profile. Smoke timings were explicitly discarded.

+
Commitcb376087bcOutcomePrivate, reproducible launch state
+
+ +
+
Stage 3
+

Normalize macOS display geometry

1280×720 backing pixels and effective UI scale 1.0 on 1× and 2× displays.

+

A native Retina smoke proved the geometry contract. Its timing was discarded because display proof is not a baseline.

+
Commit6f726d9985OutcomeDisplay contract complete
+
+ +
+
Stage 4
+

Make scene validity fail closed

Schema 3, viewer facts, typed operator assertions, independent gate validation, and privacy.

+

This new reanalysis split supersedes the former combined Mac Stage 3. The contract landed without retained performance evidence.

+
Commitperf(renderer): enforce controlled scene validityOutcomeBaseline may now run from a committed definition
+
+ +
+
Stage 5
+

Capture Apple Silicon native OpenGL baseline

One clean build, one warm prime, five valid measured repeats.

+

Characterize the current renderer on Apple Silicon. Stop if the controlled scene cannot pass every schema-3 gate. Keep raw results private.

+
Depends onCommitted Stage 4 contractProposed commitperf(renderer): capture Apple Silicon OpenGL baseline
+
+ +
+
Stage 6
+

Match an older hardware control

Same platform, workload, commit, settings, and validity contract.

+

Provisional. Determine whether the report is a hardware-generation regression rather than a demanding scene or platform-wide behavior.

+
Depends onStable Stage 5 baselineIntentMatched control evidence only
+
+ +
+
Stage 7
+

Trace the dominant slow-frame path

Separate diagnostic capture, per-thread CPU, API waits, present, and GPU passes.

+

Provisional. Select one bounded current-path correction or one architecture experiment. Do not infer a Vulkan benefit from utilization alone.

+
Depends onMatched affected/control evidenceIntentDiagnosis and next decision
+
+ +
+
Stage 8
+

Correct the measured path or prove a dual-API seam

The Stage 7 evidence chooses exactly one.

+

Provisional. Prefer a bounded OpenGL fix when scene preparation or one current-path defect dominates. Use a tiny Vulkan spike only when the trace or a separately approved platform-longevity goal justifies it.

+
Depends onStage 7 decisionIntentOne measured intervention
+
+ +
+
Later
+

Native backend migration, if approved

Frame data, resources, shaders, platform shell, then rendering families.

+

Coarse sequence: immutable frame packets; typed resource lifetime and synchronization; shader variants and SPIR-V; Windows, Linux, and MoltenVK surfaces; basic presentation; opaque/PBR; shadows and lighting; alpha and particles; water and sky; avatars; UI/HUD; streaming, media, snapshots, readback; device loss; rollout and OpenGL retirement.

+
Depends onEvidence-backed approval and per-stage reanalysisRuleNo big-bang rewrite or permanent translation layer by assumption
+
+
+ +
+ Architecture decision remains open. Zink is useful Linux evidence and a possible bridge, not a cross-platform conclusion. MoltenVK is a Vulkan platform layer, not OpenGL compatibility. A native Vulkan backend should grow beside OpenGL through a narrow seam only after the measurements identify a benefit or a separate platform-support mandate funds it. +
+
+
+ + + + + + +
+
+

Only detailed next stage

+

Stage 5: controlled Apple Silicon native OpenGL baseline

+

Collect five valid measurements from a clean build of the committed schema-3 contract. This stage characterizes the current path. It does not compare hardware classes, trace a diagnosis, change renderer code, or choose Vulkan.

+ +
+
+

Objective

+

Answer two questions: can the controlled scene pass every gate without changing the contract, and what is the run-to-run shape of the current Apple Silicon OpenGL baseline?

+
+
+
+

In scope

+
    +
  • Clean universal ReleaseOS build
  • +
  • One unmeasured warm prime
  • +
  • Five valid native OpenGL repeats
  • +
  • Sanitized aggregate and decision
  • +
+
+
+

Out of scope

+
    +
  • Older-hardware comparison
  • +
  • External GPU trace
  • +
  • OpenGL or Vulkan changes
  • +
  • Threshold tuning after seeing timing
  • +
+
+
+
+ +
+
+ +
+

Start from the committed contract

+

Pull the Stage 4 commit into a clean macOS checkout. Confirm no local renderer or benchmark edits and record the source revision.

+
    +
  • Do not run from an uncommitted gate definition.
  • +
  • Do not reuse results produced by schema 1 or 2.
  • +
  • Do not carry a temporary smoke manifest into the baseline.
  • +
+
+
ProofEvery result reports the Stage 4 commit, an empty tracked-diff hash, git_dirty: false, schema 3, and the checked-in manifest hash.
+
+ +
+ +
+

Build and verify the Mac viewer

+

Use the documented Xcode compiler and SDK route with a Nix-provided CMake executable. Configure ReleaseOS with benchmark instrumentation and tests on, signing and crash reporting off.

+
    +
  • Run the complete Python benchmark suite.
  • +
  • Validate both schemas, all six manifests, and the schema-3 fixture.
  • +
  • Run the focused display and macOS directory integration tests.
  • +
  • Compile and package the universal benchmark-enabled app.
  • +
+
+
ProofThe build and tests pass before login. A normal daily-driver profile, signing identity, and production channel are not touched.
+
+ +
+ +
+

Prepare private inputs

+

Keep credentials, SLURL, raw output, and any visual reference outside Git. Use steady-warm-v1.json unchanged and assign one stable, non-identifying workload slug.

+
    +
  • Set the credential file to mode 0600.
  • +
  • Confirm the chosen SLURL reaches the controlled scene and intended fixed view.
  • +
  • Record truthful power source, low-power mode, thermal state, scene-event, UI, and camera assertions immediately before the batch.
  • +
  • Use the same power source for all five repeats. Stop on low-power mode, unknown state, or throttling.
  • +
+
+
ProofA dry run prints neither login identity nor SLURL. No private input is inside the repository or plan.
+
+ +
+ +
+

Run one prime and five measured repeats

+

Use native Apple OpenGL with the expected Apple GPU. The runner performs a full unmeasured warm prime, then five 30-second warm-up and 120-second capture repeats in the same isolated warm state.

+
+
python3 scripts/perf/render_benchmark.py run \
+  --viewer "/path/to/Second Life Test.app/Contents/MacOS/Second Life Test" \
+  --manifest scripts/perf/scenarios/steady-warm-v1.json \
+  --credential-file /private/path/benchmark-account.txt \
+  --slurl secondlife://operator-supplied-location \
+  --hardware-label mac-apple-silicon-current \
+  --workload-id controlled-steady-scene \
+  --power-source ac \
+  --low-power-mode off \
+  --thermal-state nominal \
+  --scene-events none \
+  --ui-state approved \
+  --camera-state approved \
+  --expect-gpu-substring Apple \
+  --backend native-gl \
+  --output-dir /private/path/renderer-results
+
+

The shown power and thermal values are examples, not defaults. Supply the truthful state. Do not touch the avatar, camera, focus, or controlled UI after the prime begins.

+
+
ProofExactly five measured artifacts validate. Any rejected run is excluded and its failed-gate names are counted separately.
+
+ +
+ +
+

Analyze without broadening the claim

+

Generate the standard report without --allow-mismatch. Summarize distribution and existing phase/resource facts. A single baseline has no backend or hardware delta.

+
    +
  • Report median, p95, p99, worst frame, 1 percent low FPS, and run-to-run p95 range.
  • +
  • Summarize dominant CPU phases and resource-counter deltas.
  • +
  • List rejected-run counts and gate names without rejected timing.
  • +
  • Keep raw results and any image private; check in only a sanitized aggregate.
  • +
+
+
ProofThe report passes comparison guards and contains no location, account, absolute camera, host, or machine identifier.
+
+ +
+ +
+

Decide and replan

+

Write the Stage 5 decision, update this same URL, and detail exactly one next stage.

+
    +
  • If five valid runs are stable, select the matched older-hardware control as Stage 6.
  • +
  • If the scene cannot pass without changing the contract, record the blocking gates and detail one gate investigation instead.
  • +
  • If the data is stable but an unexpected existing phase dominates, record it without implementing a fix.
  • +
+
+
ProofThe stage ends with one privacy-safe commit and one detailed successor. No Stage 6 execution is included.
+
+
+ +
+

Stage 5 exit gates

+
+
01

Clean source and build

All five results identify the committed Stage 4 source, ReleaseOS build, native Apple OpenGL backend, expected GPU, and clean tracked tree.

+
02

Five results pass every contract

Display, placement, focus, camera, avatar, UI, assets, population, circuit, ping, power, thermal, and scene-event gates all pass.

+
03

Comparison identity holds

Manifest, settings, source, workload, operator assertions, view hash, stable population, build, and instrumentation match without an override.

+
04

Evidence is privacy-safe

Only a sanitized aggregate and decision enter Git. Raw results, SLURL, credentials, and visual material remain private.

+
05

The conclusion stays narrow

The record describes one Apple Silicon native OpenGL baseline and its noise. It claims no hardware regression or Vulkan benefit.

+
06

One next stage is selected

The updated plan details either the matched control or one blocking-gate investigation, never both.

+
+
+ +
+

Stage 5 stop conditions

+
+

The asset gate never settles

The composite all-zero rule is intentionally stricter than the historical mesh-only horizon.

Stop. Preserve failed sub-gate names and replan an asset-gate investigation. Do not loosen it after seeing timings.
+

The live scene changes

Visitors, object updates, simulator events, or camera drift can invalidate the batch.

Stop. Reject affected runs. Change the controlled scene only between complete batches and keep the workload slug honest.
+

Power or thermal state changes

Battery/AC changes, low-power mode, or throttling can dominate a small frame-time delta.

Stop. Do not mix states. Let the machine recover, start a new batch, and retain no rejected timing.
+

Five runs tempt a regression claim

No older matched control exists yet.

Limit. Publish baseline shape and noise only. Hardware-class comparison remains Stage 6.
+
+
+ +
+

Stage 5 commit boundary

+
+

perf(renderer): capture Apple Silicon OpenGL baseline

+

One commit contains a sanitized aggregate, the Stage 5 decision, and the replanned next stage. It contains no raw result, private location, threshold change, renderer fix, Vulkan dependency, external trace, or older-control execution.

+
Before commit
+
+[ ] Clean Stage 4 source and universal ReleaseOS build verified
+[ ] Python, schema, display, and macOS integration checks pass
+[ ] One unmeasured prime and exactly five valid measured runs complete
+[ ] Standard report passes without --allow-mismatch
+[ ] Rejected runs contribute no timing
+[ ] Sanitized aggregate contains no private input or absolute view
+[ ] Decision makes no hardware or Vulkan comparison
+[ ] This same plan details exactly one next stage
+[ ] Working tree contains no unrelated user changes
+
+
+
+
+ +
+
+

Mandatory reanalysis at the end of every stage

+

This is part of the stage, not a meeting after the commit. The commit lands only after the plan reflects what the stage taught us.

+ +
+
+

Record actuals

+
    +
  • What changed, including files and public interfaces
  • +
  • Which tests ran and on which platforms
  • +
  • Measured frame time, memory, visual, and stability effects
  • +
  • New constraints, failures, and unresolved questions
  • +
  • Work deliberately left for later
  • +
+
+
+

Challenge the roadmap

+
    +
  • Does the next stage still address the highest-value dependency?
  • +
  • Did the completed work expose a smaller or safer boundary?
  • +
  • Must the next stage split to remain one reviewable commit?
  • +
  • Can any later stage disappear?
  • +
  • Do platform, shader, resource, or test assumptions need correction?
  • +
+
+
+

Update this document

+
    +
  • Mark the completed stage and link its commit
  • +
  • Add an actual-results note and deviations from plan
  • +
  • Revise the coarse roadmap
  • +
  • Expand exactly one next stage to the level used for Stage 1
  • +
  • Upload the same local file so the Postplan URL remains stable
  • +
+
+
+

Commit only when ready

+
    +
  • Working tree contains only the stage and plan update
  • +
  • Diff passes formatting and repository checks
  • +
  • Acceptance evidence is attached or referenced
  • +
  • Commit message describes the outcome, not the activity
  • +
  • No next-stage implementation has started
  • +
+
+
+
+
+ + +
+
+

Plan history

+
+
v3 · Stage 4 complete
Recorded macOS profile isolation and display normalization, then split the former combined baseline stage. Added the schema-3 scene-validity actuals and detailed only the controlled Apple Silicon native OpenGL baseline.
+
v2 · Stage 1 complete
Delivered the benchmark suite. Recorded the invalid live-world native/Zink matrix and limited Linux RX 480 coverage. Detailed Stage 2 around deterministic workload gates and affected-hardware traces.
+
v1 · Stage 1 ready
Created the sequential roadmap. Detailed the measurement stage. Later stages remain provisional until the Stage 1 commit and reanalysis.
+
+

Architecture basis: OpenGL performance and Vulkan migration assessment.

+
+
+
+ + + + diff --git a/indra/cmake/Variables.cmake b/indra/cmake/Variables.cmake index 405f2cd742d..9c58da59a1a 100644 --- a/indra/cmake/Variables.cmake +++ b/indra/cmake/Variables.cmake @@ -64,6 +64,12 @@ set(SCRIPTS_PREFIX ../scripts) set(VIEWER_PREFIX) set(INTEGRATION_TESTS_PREFIX) set(LL_TESTS OFF CACHE BOOL "Build and run unit and integration tests (disable for build timing runs to reduce variation") +set(LL_RENDER_BENCHMARK OFF CACHE BOOL "Build the local renderer benchmark instrumentation and LLLeap data endpoint") +set(LL_VULKAN_RUNTIME_TEST OFF CACHE BOOL "Build isolated Vulkan runtime dispatch tests") +set(LL_VULKAN_TONEMAP_TEST OFF CACHE BOOL "Build the standalone Vulkan offscreen render-contract diagnostics") +set(LL_VULKAN_VALIDATION_LAYER_DIR "" CACHE PATH "Directory containing the Khronos Vulkan validation-layer manifest") +set(LL_VULKAN_MOLTENVK_ICD "" CACHE FILEPATH "Path to the MoltenVK Vulkan ICD manifest on macOS") +set(LL_VULKAN_MACOS_X86_64_LOADER "" CACHE FILEPATH "Path to an x86_64 Vulkan loader dylib for universal macOS diagnostics") set(INCREMENTAL_LINK OFF CACHE BOOL "Use incremental linking on win32 builds (enable for faster links on some machines)") set(ENABLE_MEDIA_PLUGINS ON CACHE BOOL "Turn off building media plugins if they are imported by third-party library mechanism") set(VIEWER_SYMBOL_FILE "" CACHE STRING "Name of tarball into which to place symbol files") @@ -251,4 +257,3 @@ set(USE_PRECOMPILED_HEADERS ON CACHE BOOL "Enable use of precompiled header dire source_group("CMake Rules" FILES CMakeLists.txt) get_property(LL_GENERATOR_IS_MULTI_CONFIG GLOBAL PROPERTY GENERATOR_IS_MULTI_CONFIG) - diff --git a/indra/llfilesystem/CMakeLists.txt b/indra/llfilesystem/CMakeLists.txt index a552e4bdbc6..da8a05c0ba1 100644 --- a/indra/llfilesystem/CMakeLists.txt +++ b/indra/llfilesystem/CMakeLists.txt @@ -81,5 +81,15 @@ if (LL_TESTS) set(test_libs llmath llcommon llfilesystem ) # TODO: Some of these need refactoring to be proper Unit tests rather than Integration tests. - LL_ADD_INTEGRATION_TEST(lldir "" "${test_libs}") + if (DARWIN) + LL_ADD_INTEGRATION_TEST( + lldir + "" + "${test_libs}" + ${PYTHON_EXECUTABLE} + "${CMAKE_CURRENT_SOURCE_DIR}/tests/run_lldir_mac_isolation.py" + ) + else () + LL_ADD_INTEGRATION_TEST(lldir "" "${test_libs}") + endif () endif (LL_TESTS) diff --git a/indra/llfilesystem/lldir_mac.cpp b/indra/llfilesystem/lldir_mac.cpp index b13e72ff15e..d67805a000b 100644 --- a/indra/llfilesystem/lldir_mac.cpp +++ b/indra/llfilesystem/lldir_mac.cpp @@ -31,6 +31,7 @@ #include "lldir_mac.h" #include "llerror.h" #include "llrand.h" +#include "llstring.h" #include #include #include @@ -40,22 +41,11 @@ // -------------------------------------------------------------------------------- -static bool CreateDirectory(const std::string &parent, - const std::string &child, - std::string *fullname) +static bool CreateDirectories(const std::string &path) { - - std::filesystem::path p(parent); - p /= child; - - if (fullname) - *fullname = std::string(p.string()); - - if (! std::filesystem::create_directory(p)) - { - return (std::filesystem::is_directory(p)); - } - return true; + std::error_code error; + std::filesystem::create_directories(path, error); + return !error && std::filesystem::is_directory(path, error); } // -------------------------------------------------------------------------------- @@ -107,42 +97,31 @@ LLDir_Mac::LLDir_Mac() mSkinBaseDir = mAppRODataDir + mDirDelimiter + "skins"; } - // mOSUserDir - std::string appdir = getSystemApplicationSupportFolder(); - std::string rootdir; - - //Create root directory - if (CreateDirectory(appdir, secondLifeString, &rootdir)) + auto app_home_env = LLStringUtil::getoptenv("SECONDLIFE_USER_DIR"); + if (app_home_env && !app_home_env->empty()) { - - // Save the full path to the folder - mOSUserDir = rootdir; - - // Create our sub-dirs - CreateDirectory(rootdir, std::string("data"), NULL); - CreateDirectory(rootdir, std::string("logs"), NULL); - CreateDirectory(rootdir, std::string("user_settings"), NULL); - CreateDirectory(rootdir, std::string("browser_profile"), NULL); + mOSUserDir = *app_home_env; + mOSUserAppDir = *app_home_env; + mOSCacheDir.clear(); } - - //mOSCacheDir - std::string cachedir = getSystemCacheFolder(); - if (!cachedir.empty()) + else { - mOSCacheDir = cachedir; - //TODO: This changes from ~/Library/Cache/Secondlife to ~/Library/Cache/com.app.secondlife/Secondlife. Last dir level could go away. - CreateDirectory(mOSCacheDir, secondLifeString, NULL); + const std::string appdir = getSystemApplicationSupportFolder(); + if (!appdir.empty()) + { + mOSUserDir = (std::filesystem::path(appdir) / secondLifeString).string(); + } + + mOSCacheDir = getSystemCacheFolder(); + mOSUserAppDir = mOSUserDir; } - // mOSUserAppDir - mOSUserAppDir = mOSUserDir; - // mTempDir //Aura 120920 std::filesystem::temp_directory_path() not yet implemented on mac. :( std::string tmpdir = getSystemTempFolder(); if (!tmpdir.empty()) { - CreateDirectory(tmpdir, secondLifeString, &mTempDir); + mTempDir = (std::filesystem::path(tmpdir) / secondLifeString).string(); } mWorkingDir = getCurPath(); @@ -167,6 +146,14 @@ void LLDir_Mac::initAppDirs(const std::string &app_name, mAppRODataDir = app_read_only_data_dir; mSkinBaseDir = add(mAppRODataDir, "skins"); } + mAppName = app_name; + CreateDirectories(mOSUserAppDir); + CreateDirectories(add(mOSUserAppDir, "data")); + CreateDirectories(add(mOSUserAppDir, "logs")); + CreateDirectories(add(mOSUserAppDir, "user_settings")); + CreateDirectories(add(mOSUserAppDir, "browser_profile")); + CreateDirectories(buildSLOSCacheDir()); + CreateDirectories(mTempDir); mCAFile = add(mAppRODataDir, "ca-bundle.crt"); } diff --git a/indra/llfilesystem/lldir_utils_objc.mm b/indra/llfilesystem/lldir_utils_objc.mm index 35513d56479..ee74f8b765f 100644 --- a/indra/llfilesystem/lldir_utils_objc.mm +++ b/indra/llfilesystem/lldir_utils_objc.mm @@ -43,7 +43,8 @@ return result; } -//findSystemDirectory scoped exclusively to this file. +// Resolve a system directory without creating it. LLDir_Mac::initAppDirs() +// performs creation after the application entry-point isolation gates. std::string findSystemDirectory(NSSearchPathDirectory searchPathDirectory, NSSearchPathDomainMask domainMask) { @@ -58,12 +59,6 @@ if ([paths count]) { path = [paths objectAtIndex:0]; - //HACK: Always attempt to create directory, ignore errors. - NSError *error = nil; - - [[NSFileManager defaultManager] createDirectoryAtPath:path withIntermediateDirectories:YES attributes:nil error:&error]; - - result = std::string([path UTF8String]); } } diff --git a/indra/llfilesystem/lldir_win32.cpp b/indra/llfilesystem/lldir_win32.cpp index 912a2963c6b..0338c14d0e7 100644 --- a/indra/llfilesystem/lldir_win32.cpp +++ b/indra/llfilesystem/lldir_win32.cpp @@ -189,6 +189,17 @@ LLDir_Win32::LLDir_Win32() mTempDir = mOSUserDir; } + // Renderer parity diagnostics must never inherit the daily viewer + // profile. Honor the same isolated root as Linux and macOS before the + // constructor resolves its default cache path. + auto isolated_user_dir = LLStringUtil::getoptenv("SECONDLIFE_USER_DIR"); + if (isolated_user_dir && !isolated_user_dir->empty()) + { + mOSUserDir = *isolated_user_dir; + mOSUserAppDir = *isolated_user_dir; + mOSCacheDir.clear(); + } + /*==========================================================================*| // Now that we've got mOSUserDir, one way or another, let's see how we did // with our environment variables. @@ -280,12 +291,9 @@ LLDir_Win32::LLDir_Win32() // Build the default cache directory mDefaultCacheDir = buildSLOSCacheDir(); - // Make sure it exists - int res = LLFile::mkdir(mDefaultCacheDir); - if (res == -1) - { - LL_WARNS() << "Couldn't create LL_PATH_CACHE dir " << mDefaultCacheDir << LL_ENDL; - } + // initAppDirs() creates this after platform entry-point gates have run. + // Keeping the global constructor read-only prevents a rejected diagnostic + // invocation from creating the daily viewer cache before WinMain. mLLPluginDir = add(mExecutableDir, "llplugin"); } @@ -306,7 +314,18 @@ void LLDir_Win32::initAppDirs(const std::string &app_name, mSkinBaseDir = add(mAppRODataDir, "skins"); } mAppName = app_name; - mOSUserAppDir = add(mOSUserDir, app_name); + auto isolated_user_dir = LLStringUtil::getoptenv("SECONDLIFE_USER_DIR"); + if (isolated_user_dir && !isolated_user_dir->empty()) + { + mOSUserDir = *isolated_user_dir; + mOSUserAppDir = *isolated_user_dir; + mOSCacheDir.clear(); + mDefaultCacheDir = buildSLOSCacheDir(); + } + else + { + mOSUserAppDir = add(mOSUserDir, app_name); + } int res = LLFile::mkdir(mOSUserAppDir); if (res == -1) @@ -433,5 +452,3 @@ DWORD GetDllVersion(LPCTSTR lpszDllName) #endif #endif - - diff --git a/indra/llfilesystem/tests/lldir_test.cpp b/indra/llfilesystem/tests/lldir_test.cpp index 412feb147be..05f2b1e1dd0 100644 --- a/indra/llfilesystem/tests/lldir_test.cpp +++ b/indra/llfilesystem/tests/lldir_test.cpp @@ -716,4 +716,70 @@ namespace tut ensure_equals("path trailing slash", lldir.add("a/", "b"), "a/b"); ensure_equals("both bring slashes", lldir.add("a/", "/b"), "a/b"); } + +#if LL_DARWIN + template<> template<> + void LLDirTest_object_t::test<8>() + { + set_test_name("macOS application profile paths"); + + auto mode = LLStringUtil::getoptenv("LLDIR_TEST_MODE"); + auto root = LLStringUtil::getoptenv("LLDIR_TEST_ROOT"); + ensure("LLDIR_TEST_MODE is set", mode.has_value()); + ensure("LLDIR_TEST_ROOT is set", root.has_value()); + + // The global constructor only resolves paths. Prove it did not create + // even the system base directories before the application entry-point + // isolation gate would run. + if (*mode == "override") + { + ensure("override profile creation is deferred", + !std::filesystem::exists(gDirUtilp->add(*root, "user"))); + } + else if (*mode == "standard") + { + const std::string home = gDirUtilp->add(*root, "home"); + ensure("Application Support creation is deferred", + !std::filesystem::exists(gDirUtilp->add(home, "Library", "Application Support"))); + ensure("Caches creation is deferred", + !std::filesystem::exists(gDirUtilp->add(home, "Library", "Caches"))); + } + + gDirUtilp->initAppDirs("SecondLife"); + + if (*mode == "override") + { + const std::string profile = gDirUtilp->add(*root, "user"); + ensure_equals("override OS user directory", gDirUtilp->getOSUserDir(), profile); + ensure_equals("override application directory", gDirUtilp->getOSUserAppDir(), profile); + ensure_equals("override leaves the system cache root unused", gDirUtilp->getOSCacheDir(), ""); + ensure_equals("override default cache", gDirUtilp->getCacheDir(), gDirUtilp->add(profile, "cache")); + + for (const std::string& child : { "data", "logs", "user_settings", "browser_profile", "cache" }) + { + ensure(child + " is inside the override", std::filesystem::is_directory(gDirUtilp->add(profile, child))); + } + + gDirUtilp->setLindenUserDir("Fixture Resident"); + ensure_equals("account data is inside the override", + gDirUtilp->getLindenUserDir(), + gDirUtilp->add(profile, "fixture_resident")); + } + else if (*mode == "standard") + { + const std::string home = gDirUtilp->add(*root, "home"); + const std::string profile = gDirUtilp->add(home, "Library", "Application Support", "SecondLife"); + const std::string cache_root = gDirUtilp->add(home, "Library", "Caches"); + + ensure_equals("standard OS user directory", gDirUtilp->getOSUserDir(), profile); + ensure_equals("standard application directory", gDirUtilp->getOSUserAppDir(), profile); + ensure_equals("standard system cache root", gDirUtilp->getOSCacheDir(), cache_root); + ensure_equals("standard default cache", gDirUtilp->getCacheDir(), gDirUtilp->add(cache_root, "SecondLife")); + } + else + { + fail("unknown LLDIR_TEST_MODE"); + } + } +#endif } diff --git a/indra/llfilesystem/tests/run_lldir_mac_isolation.py b/indra/llfilesystem/tests/run_lldir_mac_isolation.py new file mode 100644 index 00000000000..a4d5f497fd6 --- /dev/null +++ b/indra/llfilesystem/tests/run_lldir_mac_isolation.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 + +from __future__ import annotations + +import os +from pathlib import Path +import stat +import subprocess +import sys +import tempfile + + +def snapshot_tree(path: Path) -> tuple[tuple[str, int, int, int, int], ...] | None: + if not path.exists(): + return None + + entries = [path, *path.rglob("*")] + snapshot = [] + for entry in entries: + metadata = entry.lstat() + snapshot.append( + ( + str(entry.relative_to(path)), + stat.S_IFMT(metadata.st_mode), + metadata.st_size, + metadata.st_mtime_ns, + metadata.st_ctime_ns, + ) + ) + return tuple(sorted(snapshot)) + + +def run_test(executable: Path, mode: str, root: Path) -> None: + environment = os.environ.copy() + environment["LLDIR_TEST_MODE"] = mode + environment["LLDIR_TEST_ROOT"] = str(root) + + if mode == "override": + environment["SECONDLIFE_USER_DIR"] = str(root / "user") + environment.pop("CFFIXED_USER_HOME", None) + else: + environment.pop("SECONDLIFE_USER_DIR", None) + environment["CFFIXED_USER_HOME"] = str(root / "home") + + subprocess.run([executable], env=environment, check=True) + + +def main() -> int: + if len(sys.argv) != 2: + raise SystemExit("usage: run_lldir_mac_isolation.py TEST_EXECUTABLE") + + executable = Path(sys.argv[1]).resolve() + standard_profile = Path.home() / "Library" / "Application Support" / "SecondLife" + standard_cache = Path.home() / "Library" / "Caches" / "SecondLife" + before = (snapshot_tree(standard_profile), snapshot_tree(standard_cache)) + + with tempfile.TemporaryDirectory(prefix="lldir-mac-isolation-") as temp_name: + temp = Path(temp_name) + run_test(executable, "override", temp / "override") + run_test(executable, "standard", temp / "standard") + + after = (snapshot_tree(standard_profile), snapshot_tree(standard_cache)) + if before != after: + raise RuntimeError("the isolated LLDir tests changed the normal macOS profile or cache") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/indra/llrender/CMakeLists.txt b/indra/llrender/CMakeLists.txt index 82ffa5dde46..600f4a2ac36 100644 --- a/indra/llrender/CMakeLists.txt +++ b/indra/llrender/CMakeLists.txt @@ -23,6 +23,9 @@ set(llrender_SOURCE_FILES llgltexture.cpp llimagegl.cpp llrender.cpp + llrenderglmaterial.cpp + llrendergltextureupload.cpp + llrendergltonemap.cpp llrender2dutils.cpp llrendernavprim.cpp llrendersphere.cpp @@ -54,6 +57,9 @@ set(llrender_HEADER_FILES llgltypes.h llimagegl.h llrender.h + llrenderglmaterial.h + llrendergltextureupload.h + llrendergltonemap.h llrender2dutils.h llrendernavprim.h llrendersphere.h @@ -68,6 +74,34 @@ set(llrender_HEADER_FILES list(APPEND llrender_SOURCE_FILES ${llrender_HEADER_FILES}) +set(llrendercontract_SOURCE_FILES + lldrawpacketcontract.cpp + lldrawpacketcontract.h + llmaterialcontract.cpp + llmaterialcontract.h + llmaterialdiagnostic.cpp + llmaterialdiagnostic.h + llmaterialparametercontract.cpp + llmaterialparametercontract.h + llrendercontract.cpp + llrendercontract.h + llshadermanifest.cpp + llshadermanifest.h + lltextureuploadcontract.cpp + lltextureuploadcontract.h + lltextureuploaddiagnostic.cpp + lltextureuploaddiagnostic.h + lltonemapcontract.cpp + lltonemapcontract.h + lltonemapdiagnostic.cpp + lltonemapdiagnostic.h + ) + +# Keep the backend-neutral contract independently linkable. In particular, +# this target must not inherit llrender's OpenGL or window dependencies. +add_library(llrendercontract ${llrendercontract_SOURCE_FILES}) +target_include_directories(llrendercontract PUBLIC ${CMAKE_CURRENT_SOURCE_DIR}) + if (BUILD_HEADLESS) add_library (llrenderheadless ${llrender_SOURCE_FILES} @@ -75,6 +109,7 @@ if (BUILD_HEADLESS) target_include_directories(llrenderheadless INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) target_link_libraries(llrenderheadless + llrendercontract llcommon llimage llmath @@ -90,12 +125,26 @@ if (BUILD_HEADLESS) endif() endif (BUILD_HEADLESS) +if (LL_RENDER_BENCHMARK) + set_property(SOURCE + llglslshader.cpp + llimagegl.cpp + llshadermgr.cpp + APPEND PROPERTY COMPILE_DEFINITIONS LL_RENDER_BENCHMARK=1) + set_property(SOURCE + llglslshader.cpp + llimagegl.cpp + llshadermgr.cpp + PROPERTY SKIP_PRECOMPILE_HEADERS ON) +endif() + add_library (llrender ${llrender_SOURCE_FILES}) target_include_directories(llrender INTERFACE ${CMAKE_CURRENT_SOURCE_DIR}) # Libraries on which this library depends, needed for Linux builds # Sort by high-level to low-level target_link_libraries(llrender + llrendercontract llcommon llimage llmath @@ -115,3 +164,816 @@ endif() if (USE_PRECOMPILED_HEADERS) target_precompile_headers(llrender REUSE_FROM llprecompiled) endif () + +if (LL_TESTS) + include(LLAddBuildTest) + set_source_files_properties( + tests/lldrawpacketcontract_test.cpp + tests/llmaterialdiagnostic_test.cpp + tests/llmaterialparametercontract_test.cpp + tests/llrendercontract_test.cpp + tests/llshadermanifest_test.cpp + tests/llrenderglmaterialregistry_test.cpp + tests/llrendergltextureuploadregistry_test.cpp + tests/llrendergltonemapregistry_test.cpp + tests/lltextureuploadcontract_test.cpp + tests/lltextureuploaddiagnostic_test.cpp + tests/lltonemapdiagnostic_test.cpp + PROPERTIES + SKIP_PRECOMPILE_HEADERS TRUE + ) + LL_ADD_INTEGRATION_TEST(lldrawpacketcontract "" "llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST(llmaterialdiagnostic "" "llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST(llmaterialparametercontract "" "llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST(llrendercontract "" "llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST(llshadermanifest "" "llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST(llrenderglmaterialregistry "" "llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST(llrendergltextureuploadregistry "" "llrender;llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST(llrendergltonemapregistry "" "llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST(lltextureuploadcontract "" "llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST(lltextureuploaddiagnostic "" "llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST(lltonemapdiagnostic "" "llrendercontract;llcommon") +endif () + +if ((LL_VULKAN_RUNTIME_TEST OR LL_VULKAN_TONEMAP_TEST) AND NOT LL_TESTS) + message(FATAL_ERROR "Vulkan runtime tests require LL_TESTS=ON") +endif () + +if (LL_VULKAN_TONEMAP_TEST) + if (NOT LINUX AND NOT DARWIN) + message(FATAL_ERROR "LL_VULKAN_TONEMAP_TEST is supported only on Linux and macOS") + endif () + + find_package(Vulkan REQUIRED) + set(llvulkan_headers_include_dir "${Vulkan_INCLUDE_DIR}") +elseif (LL_VULKAN_RUNTIME_TEST) + find_path(LL_VULKAN_HEADERS_INCLUDE_DIR + NAMES vulkan/vulkan.h + HINTS ENV VULKAN_SDK + PATH_SUFFIXES include Include) + if (NOT LL_VULKAN_HEADERS_INCLUDE_DIR) + message(FATAL_ERROR + "LL_VULKAN_RUNTIME_TEST requires Vulkan headers; add them to the include search path or set LL_VULKAN_HEADERS_INCLUDE_DIR") + endif () + set(llvulkan_headers_include_dir "${LL_VULKAN_HEADERS_INCLUDE_DIR}") +endif () + +if (LL_VULKAN_RUNTIME_TEST OR LL_VULKAN_TONEMAP_TEST) + add_library(llrendervulkanglobaldispatch STATIC + vulkan/llrendervulkanglobaldispatch.cpp + vulkan/llrendervulkanglobaldispatch.h) + target_include_directories(llrendervulkanglobaldispatch + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkanglobaldispatch SYSTEM + PUBLIC "${llvulkan_headers_include_dir}") + target_compile_definitions(llrendervulkanglobaldispatch + PRIVATE VK_NO_PROTOTYPES) + set_target_properties(llrendervulkanglobaldispatch PROPERTIES FOLDER "Tests") + + set_source_files_properties( + tests/llrendervulkanglobaldispatch_test.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + LL_ADD_INTEGRATION_TEST( + llrendervulkanglobaldispatch + "" + "llrendervulkanglobaldispatch;llcommon") + target_compile_definitions( + INTEGRATION_TEST_llrendervulkanglobaldispatch + PRIVATE VK_NO_PROTOTYPES) +endif () + +if (LL_VULKAN_TONEMAP_TEST) + find_program(LL_VULKAN_GLSLANG_VALIDATOR + NAMES glslangValidator + DOC "Path to glslangValidator for the Vulkan tonemap diagnostic") + find_program(LL_VULKAN_SPIRV_VAL + NAMES spirv-val + DOC "Path to spirv-val for the Vulkan tonemap diagnostic") + find_program(LL_VULKAN_SPIRV_CROSS + NAMES spirv-cross + DOC "Path to spirv-cross for Vulkan shader reflection") + find_program(LL_VULKAN_SPIRV_DIS + NAMES spirv-dis + DOC "Path to spirv-dis for Vulkan shader decoration validation") + + if (NOT LL_VULKAN_GLSLANG_VALIDATOR OR + NOT EXISTS "${LL_VULKAN_GLSLANG_VALIDATOR}") + message(FATAL_ERROR + "LL_VULKAN_TONEMAP_TEST requires glslangValidator; add it to PATH or set LL_VULKAN_GLSLANG_VALIDATOR") + endif () + if (NOT LL_VULKAN_SPIRV_VAL OR NOT EXISTS "${LL_VULKAN_SPIRV_VAL}") + message(FATAL_ERROR + "LL_VULKAN_TONEMAP_TEST requires spirv-val; add it to PATH or set LL_VULKAN_SPIRV_VAL") + endif () + if (NOT LL_VULKAN_SPIRV_CROSS OR NOT EXISTS "${LL_VULKAN_SPIRV_CROSS}") + message(FATAL_ERROR + "LL_VULKAN_TONEMAP_TEST requires spirv-cross; add it to PATH or set LL_VULKAN_SPIRV_CROSS") + endif () + if (NOT LL_VULKAN_SPIRV_DIS OR NOT EXISTS "${LL_VULKAN_SPIRV_DIS}") + message(FATAL_ERROR + "LL_VULKAN_TONEMAP_TEST requires spirv-dis; add it to PATH or set LL_VULKAN_SPIRV_DIS") + endif () + if (NOT LL_VULKAN_VALIDATION_LAYER_DIR OR + NOT IS_DIRECTORY "${LL_VULKAN_VALIDATION_LAYER_DIR}" OR + NOT EXISTS "${LL_VULKAN_VALIDATION_LAYER_DIR}/VkLayer_khronos_validation.json") + message(FATAL_ERROR + "LL_VULKAN_TONEMAP_TEST requires LL_VULKAN_VALIDATION_LAYER_DIR to contain VkLayer_khronos_validation.json") + endif () + + set(llvulkan_shader_source_dir "${CMAKE_CURRENT_SOURCE_DIR}/vulkan/shaders") + set(llvulkan_shader_binary_dir "${CMAKE_CURRENT_BINARY_DIR}/vulkan/shaders") + set(llvulkan_vertex_source "${llvulkan_shader_source_dir}/tonemap.vert.glsl") + set(llvulkan_fragment_source "${llvulkan_shader_source_dir}/tonemap.frag.glsl") + set(llvulkan_vertex_spv "${llvulkan_shader_binary_dir}/tonemap.vert.spv") + + add_custom_command( + OUTPUT "${llvulkan_vertex_spv}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${llvulkan_shader_binary_dir}" + COMMAND "${CMAKE_COMMAND}" -E remove -f "${llvulkan_vertex_spv}.tmp" + COMMAND "${LL_VULKAN_GLSLANG_VALIDATOR}" + -V --target-env vulkan1.1 -S vert + "-I${llvulkan_shader_source_dir}" + -o "${llvulkan_vertex_spv}.tmp" + "${llvulkan_vertex_source}" + COMMAND "${LL_VULKAN_SPIRV_VAL}" + --target-env vulkan1.1 "${llvulkan_vertex_spv}.tmp" + COMMAND "${CMAKE_COMMAND}" -E rename + "${llvulkan_vertex_spv}.tmp" "${llvulkan_vertex_spv}" + DEPENDS + "${llvulkan_vertex_source}" + "${CMAKE_CURRENT_SOURCE_DIR}/../newview/app_settings/shaders/class1/deferred/postDeferredNoTCV.glsl" + COMMENT "Compiling and validating Vulkan tonemap vertex shader" + VERBATIM + ) + + set(llvulkan_fragment_spv_files) + foreach (llvulkan_variant IN ITEMS 0 1 2 3 6 7) + set(llvulkan_variant_defines) + if (llvulkan_variant EQUAL 1 OR llvulkan_variant EQUAL 3 OR + llvulkan_variant EQUAL 7) + list(APPEND llvulkan_variant_defines -DLL_TONEMAP_NO_POST=1) + endif () + if (llvulkan_variant EQUAL 2 OR llvulkan_variant EQUAL 3 OR + llvulkan_variant EQUAL 6 OR llvulkan_variant EQUAL 7) + list(APPEND llvulkan_variant_defines -DLL_TONEMAP_GAMMA_CORRECT=1) + endif () + if (llvulkan_variant EQUAL 6 OR llvulkan_variant EQUAL 7) + list(APPEND llvulkan_variant_defines -DLL_TONEMAP_LEGACY_GAMMA=1) + endif () + + set(llvulkan_fragment_spv + "${llvulkan_shader_binary_dir}/tonemap.frag.${llvulkan_variant}.spv") + add_custom_command( + OUTPUT "${llvulkan_fragment_spv}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${llvulkan_shader_binary_dir}" + COMMAND "${CMAKE_COMMAND}" -E remove -f "${llvulkan_fragment_spv}.tmp" + COMMAND "${LL_VULKAN_GLSLANG_VALIDATOR}" + -V --target-env vulkan1.1 -S frag + "-I${llvulkan_shader_source_dir}" + ${llvulkan_variant_defines} + -o "${llvulkan_fragment_spv}.tmp" + "${llvulkan_fragment_source}" + COMMAND "${LL_VULKAN_SPIRV_VAL}" + --target-env vulkan1.1 "${llvulkan_fragment_spv}.tmp" + COMMAND "${CMAKE_COMMAND}" -E rename + "${llvulkan_fragment_spv}.tmp" "${llvulkan_fragment_spv}" + DEPENDS + "${llvulkan_fragment_source}" + "${CMAKE_CURRENT_SOURCE_DIR}/../newview/app_settings/shaders/class1/environment/srgbF.glsl" + "${CMAKE_CURRENT_SOURCE_DIR}/../newview/app_settings/shaders/class1/deferred/tonemapUtilF.glsl" + "${CMAKE_CURRENT_SOURCE_DIR}/../newview/app_settings/shaders/class1/deferred/postDeferredTonemap.glsl" + COMMENT "Compiling and validating Vulkan tonemap fragment shader variant ${llvulkan_variant}" + VERBATIM + ) + list(APPEND llvulkan_fragment_spv_files "${llvulkan_fragment_spv}") + endforeach () + + add_custom_target(llvulkantonemap_shaders + DEPENDS "${llvulkan_vertex_spv}" ${llvulkan_fragment_spv_files}) + set_target_properties(llvulkantonemap_shaders PROPERTIES FOLDER "Tests") + + set(llvulkan_material_vertex_source + "${llvulkan_shader_source_dir}/material.vert.glsl") + set(llvulkan_material_fragment_source + "${llvulkan_shader_source_dir}/material.frag.glsl") + + add_executable(llshadermanifest_dump + vulkan/llshadermanifest_dump.cpp) + target_link_libraries(llshadermanifest_dump PRIVATE llrendercontract) + set_target_properties(llshadermanifest_dump PROPERTIES FOLDER "Tests") + + set(llvulkan_material_reflection_verifier + "${CMAKE_CURRENT_SOURCE_DIR}/vulkan/verify_material_reflection.py") + set(llvulkan_material_reflection_verifier_test + "${CMAKE_CURRENT_SOURCE_DIR}/vulkan/test_verify_material_reflection.py") + set(llvulkan_material_hash_writer + "${CMAKE_CURRENT_SOURCE_DIR}/vulkan/write_shader_hashes.cmake") + + function(ll_add_vulkan_material_shader_profile profile artifact_stem target_name) + set(profile_defines ${ARGN}) + set(artifact_path "${llvulkan_shader_binary_dir}/${artifact_stem}") + set(vertex_spv "${artifact_path}.vert.spv") + set(fragment_spv "${artifact_path}.frag.spv") + set(vertex_reflection "${artifact_path}.vert.reflect.json") + set(fragment_reflection "${artifact_path}.frag.reflect.json") + set(vertex_disassembly "${artifact_path}.vert.spvasm") + set(fragment_disassembly "${artifact_path}.frag.spvasm") + set(manifest_expectation "${artifact_path}.manifest.json") + set(module_hashes "${artifact_path}.modules.sha256") + set(manifest_stamp "${artifact_path}.manifest.verified") + + add_custom_command( + OUTPUT "${vertex_spv}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${llvulkan_shader_binary_dir}" + COMMAND "${CMAKE_COMMAND}" -E remove -f "${vertex_spv}.tmp" + COMMAND "${LL_VULKAN_GLSLANG_VALIDATOR}" + -V --target-env vulkan1.1 -S vert + "-I${llvulkan_shader_source_dir}" + ${profile_defines} + -o "${vertex_spv}.tmp" + "${llvulkan_material_vertex_source}" + COMMAND "${LL_VULKAN_SPIRV_VAL}" + --target-env vulkan1.1 "${vertex_spv}.tmp" + COMMAND "${CMAKE_COMMAND}" -E rename + "${vertex_spv}.tmp" + "${vertex_spv}" + DEPENDS + "${llvulkan_material_vertex_source}" + "${CMAKE_CURRENT_SOURCE_DIR}/../newview/app_settings/shaders/class1/deferred/materialV.glsl" + COMMENT "Compiling and validating ${profile} Vulkan material vertex shader" + VERBATIM + ) + + add_custom_command( + OUTPUT "${fragment_spv}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${llvulkan_shader_binary_dir}" + COMMAND "${CMAKE_COMMAND}" -E remove -f "${fragment_spv}.tmp" + COMMAND "${LL_VULKAN_GLSLANG_VALIDATOR}" + -V --target-env vulkan1.1 -S frag + "-I${llvulkan_shader_source_dir}" + ${profile_defines} + -o "${fragment_spv}.tmp" + "${llvulkan_material_fragment_source}" + COMMAND "${LL_VULKAN_SPIRV_VAL}" + --target-env vulkan1.1 "${fragment_spv}.tmp" + COMMAND "${CMAKE_COMMAND}" -E rename + "${fragment_spv}.tmp" + "${fragment_spv}" + DEPENDS + "${llvulkan_material_fragment_source}" + "${CMAKE_CURRENT_SOURCE_DIR}/../newview/app_settings/shaders/class1/deferred/globalF.glsl" + "${CMAKE_CURRENT_SOURCE_DIR}/../newview/app_settings/shaders/class3/deferred/materialF.glsl" + COMMENT "Compiling and validating ${profile} Vulkan material fragment shader" + VERBATIM + ) + + add_custom_command( + OUTPUT + "${manifest_stamp}" + "${module_hashes}" + BYPRODUCTS + "${vertex_reflection}" + "${fragment_reflection}" + "${vertex_disassembly}" + "${fragment_disassembly}" + "${manifest_expectation}" + COMMAND "${CMAKE_COMMAND}" -E remove -f + "${manifest_stamp}" + "${module_hashes}" + "${vertex_reflection}" + "${fragment_reflection}" + "${vertex_disassembly}" + "${fragment_disassembly}" + "${manifest_expectation}" + COMMAND "${LL_VULKAN_SPIRV_CROSS}" + "${vertex_spv}" + --reflect --output "${vertex_reflection}" + COMMAND "${LL_VULKAN_SPIRV_CROSS}" + "${fragment_spv}" + --reflect --output "${fragment_reflection}" + COMMAND "${LL_VULKAN_SPIRV_DIS}" + "${vertex_spv}" + -o "${vertex_disassembly}" + COMMAND "${LL_VULKAN_SPIRV_DIS}" + "${fragment_spv}" + -o "${fragment_disassembly}" + COMMAND "$" + --profile "${profile}" + --output "${manifest_expectation}" + COMMAND "${PYTHON_EXECUTABLE}" -B + "${llvulkan_material_reflection_verifier_test}" + COMMAND "${PYTHON_EXECUTABLE}" + "${llvulkan_material_reflection_verifier}" + --expectation "${manifest_expectation}" + --vertex-reflection "${vertex_reflection}" + --fragment-reflection "${fragment_reflection}" + --vertex-disassembly "${vertex_disassembly}" + --fragment-disassembly "${fragment_disassembly}" + COMMAND "${CMAKE_COMMAND}" + "-DVERTEX_MODULE=${vertex_spv}" + "-DFRAGMENT_MODULE=${fragment_spv}" + "-DOUTPUT_FILE=${module_hashes}" + -P "${llvulkan_material_hash_writer}" + COMMAND "${CMAKE_COMMAND}" -E touch + "${manifest_stamp}" + DEPENDS + "${vertex_spv}" + "${fragment_spv}" + llshadermanifest_dump + "${llvulkan_material_reflection_verifier}" + "${llvulkan_material_reflection_verifier_test}" + "${llvulkan_material_hash_writer}" + COMMENT "Reflecting and validating the ${profile} Vulkan material manifest" + VERBATIM + ) + + add_custom_target(${target_name} + DEPENDS + "${vertex_spv}" + "${fragment_spv}" + "${manifest_stamp}" + "${module_hashes}") + set_target_properties(${target_name} PROPERTIES FOLDER "Tests") + + set(${target_name}_VERTEX_SPV "${vertex_spv}" PARENT_SCOPE) + set(${target_name}_FRAGMENT_SPV "${fragment_spv}" PARENT_SCOPE) + set(${target_name}_VERIFIED_STAMP "${manifest_stamp}" PARENT_SCOPE) + endfunction() + + ll_add_vulkan_material_shader_profile( + diagnostic material llvulkanmaterial_shaders) + ll_add_vulkan_material_shader_profile( + production material.production llvulkanmaterialproduction_shaders + -DLL_VULKAN_MATERIAL_PRODUCTION=1) + + # The viewer manifest is configured later from a sibling directory. Pass + # the exact validated-output directory through the common parent scope. + set(LL_VULKAN_MATERIAL_PRODUCTION_ARTIFACT_DIR + "${llvulkan_shader_binary_dir}" + PARENT_SCOPE) + set(LL_VULKAN_MATERIAL_PRODUCTION_VERTEX_SPV + "${llvulkanmaterialproduction_shaders_VERTEX_SPV}" + PARENT_SCOPE) + set(LL_VULKAN_MATERIAL_PRODUCTION_FRAGMENT_SPV + "${llvulkanmaterialproduction_shaders_FRAGMENT_SPV}" + PARENT_SCOPE) + set(LL_VULKAN_MATERIAL_PRODUCTION_VERIFIED_STAMP + "${llvulkanmaterialproduction_shaders_VERIFIED_STAMP}" + PARENT_SCOPE) + + add_library(llvulkanmaterialartifact STATIC + vulkan/llvulkanmaterialartifact.cpp + vulkan/llvulkanmaterialartifact.h) + target_include_directories(llvulkanmaterialartifact + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_link_libraries(llvulkanmaterialartifact PUBLIC llrendercontract) + set_target_properties(llvulkanmaterialartifact PROPERTIES FOLDER "Tests") + + set_source_files_properties( + tests/llvulkanmaterialartifact_test.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + LL_ADD_INTEGRATION_TEST( + llvulkanmaterialartifact + "" + "llvulkanmaterialartifact;llrendercontract;llcommon") + + add_library(llvulkanmaterialpublication STATIC + vulkan/llvulkanmaterialpublication.cpp + vulkan/llvulkanmaterialpublication.h) + target_include_directories(llvulkanmaterialpublication + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_link_libraries(llvulkanmaterialpublication + PUBLIC llvulkanmaterialartifact) + set_target_properties(llvulkanmaterialpublication PROPERTIES FOLDER "Tests") + + set_source_files_properties( + tests/llvulkanmaterialpublication_test.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + LL_ADD_INTEGRATION_TEST( + llvulkanmaterialpublication + "" + "llvulkanmaterialpublication;llcommon") + + add_library(llrendervulkanmaterialmodule STATIC + vulkan/llrendervulkanmaterialmodule.cpp + vulkan/llrendervulkanmaterialmodule.h) + target_include_directories(llrendervulkanmaterialmodule + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkanmaterialmodule SYSTEM + PUBLIC "${Vulkan_INCLUDE_DIR}") + target_compile_definitions(llrendervulkanmaterialmodule + PRIVATE VK_NO_PROTOTYPES) + target_link_libraries(llrendervulkanmaterialmodule + PUBLIC llvulkanmaterialpublication) + set_target_properties(llrendervulkanmaterialmodule PROPERTIES FOLDER "Tests") + + set_source_files_properties( + tests/llrendervulkanmaterialmodule_test.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + LL_ADD_INTEGRATION_TEST( + llrendervulkanmaterialmodule + "" + "llrendervulkanmaterialmodule;llcommon") + target_compile_definitions( + INTEGRATION_TEST_llrendervulkanmaterialmodule + PRIVATE VK_NO_PROTOTYPES) + + add_library(llrendervulkanmateriallayout STATIC + vulkan/llrendervulkanmateriallayout.cpp + vulkan/llrendervulkanmateriallayout.h) + target_include_directories(llrendervulkanmateriallayout + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkanmateriallayout SYSTEM + PUBLIC "${Vulkan_INCLUDE_DIR}") + target_compile_definitions(llrendervulkanmateriallayout + PRIVATE VK_NO_PROTOTYPES) + set_target_properties(llrendervulkanmateriallayout PROPERTIES FOLDER "Tests") + + set_source_files_properties( + tests/llrendervulkanmateriallayout_test.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + LL_ADD_INTEGRATION_TEST( + llrendervulkanmateriallayout + "" + "llrendervulkanmateriallayout;llrendercontract;llcommon") + target_compile_definitions( + INTEGRATION_TEST_llrendervulkanmateriallayout + PRIVATE VK_NO_PROTOTYPES) + + add_library(llrendervulkanmaterialdescriptor STATIC + vulkan/llrendervulkanmaterialdescriptor.cpp + vulkan/llrendervulkanmaterialdescriptor.h) + target_include_directories(llrendervulkanmaterialdescriptor + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkanmaterialdescriptor SYSTEM + PUBLIC "${Vulkan_INCLUDE_DIR}") + target_compile_definitions(llrendervulkanmaterialdescriptor + PRIVATE VK_NO_PROTOTYPES) + target_link_libraries(llrendervulkanmaterialdescriptor + PUBLIC llrendervulkanmateriallayout llrendercontract) + set_target_properties(llrendervulkanmaterialdescriptor PROPERTIES FOLDER "Tests") + + set_source_files_properties( + tests/llrendervulkanmaterialdescriptor_test.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + LL_ADD_INTEGRATION_TEST( + llrendervulkanmaterialdescriptor + "" + "llrendervulkanmaterialdescriptor;llrendercontract;llcommon") + target_compile_definitions( + INTEGRATION_TEST_llrendervulkanmaterialdescriptor + PRIVATE VK_NO_PROTOTYPES) + + add_library(llrendervulkanmaterialattachment STATIC + vulkan/llrendervulkanmaterialattachment.cpp + vulkan/llrendervulkanmaterialattachment.h) + target_include_directories(llrendervulkanmaterialattachment + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkanmaterialattachment SYSTEM + PUBLIC "${Vulkan_INCLUDE_DIR}") + target_compile_definitions(llrendervulkanmaterialattachment + PRIVATE VK_NO_PROTOTYPES) + target_link_libraries(llrendervulkanmaterialattachment + PUBLIC llrendercontract) + set_target_properties(llrendervulkanmaterialattachment PROPERTIES FOLDER "Tests") + + set_source_files_properties( + tests/llrendervulkanmaterialattachment_test.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + LL_ADD_INTEGRATION_TEST( + llrendervulkanmaterialattachment + "" + "llrendervulkanmaterialattachment;llrendercontract;llcommon") + target_compile_definitions( + INTEGRATION_TEST_llrendervulkanmaterialattachment + PRIVATE VK_NO_PROTOTYPES) + + add_library(llrendervulkanmaterialcapability STATIC + vulkan/llrendervulkanmaterialcapability.cpp + vulkan/llrendervulkanmaterialcapability.h) + target_include_directories(llrendervulkanmaterialcapability + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkanmaterialcapability SYSTEM + PUBLIC "${Vulkan_INCLUDE_DIR}") + target_compile_definitions(llrendervulkanmaterialcapability + PRIVATE VK_NO_PROTOTYPES VK_ENABLE_BETA_EXTENSIONS) + target_link_libraries(llrendervulkanmaterialcapability + PUBLIC llrendervulkanmaterialattachment) + set_target_properties(llrendervulkanmaterialcapability PROPERTIES FOLDER "Tests") + + set_source_files_properties( + tests/llrendervulkanmaterialcapability_test.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + LL_ADD_INTEGRATION_TEST( + llrendervulkanmaterialcapability + "" + "llrendervulkanmaterialcapability;llrendervulkanmaterialattachment;llrendercontract;llcommon") + target_compile_definitions( + INTEGRATION_TEST_llrendervulkanmaterialcapability + PRIVATE VK_NO_PROTOTYPES VK_ENABLE_BETA_EXTENSIONS) + + add_library(llrendervulkanmaterialpipelinecache STATIC + vulkan/llrendervulkanmaterialpipelinecache.cpp + vulkan/llrendervulkanmaterialpipelinecache.h) + target_include_directories(llrendervulkanmaterialpipelinecache + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkanmaterialpipelinecache SYSTEM + PUBLIC "${Vulkan_INCLUDE_DIR}") + target_compile_definitions(llrendervulkanmaterialpipelinecache + PRIVATE VK_NO_PROTOTYPES) + set_target_properties(llrendervulkanmaterialpipelinecache PROPERTIES FOLDER "Tests") + + set_source_files_properties( + tests/llrendervulkanmaterialpipelinecache_test.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + LL_ADD_INTEGRATION_TEST( + llrendervulkanmaterialpipelinecache + "" + "llrendervulkanmaterialpipelinecache;llcommon") + target_compile_definitions( + INTEGRATION_TEST_llrendervulkanmaterialpipelinecache + PRIVATE VK_NO_PROTOTYPES) + + add_library(llrendervulkanmaterialrenderpass STATIC + vulkan/llrendervulkanmaterialrenderpass.cpp + vulkan/llrendervulkanmaterialrenderpass.h) + target_include_directories(llrendervulkanmaterialrenderpass + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkanmaterialrenderpass SYSTEM + PUBLIC "${Vulkan_INCLUDE_DIR}") + target_compile_definitions(llrendervulkanmaterialrenderpass + PRIVATE VK_NO_PROTOTYPES) + target_link_libraries(llrendervulkanmaterialrenderpass + PUBLIC llrendervulkanmaterialattachment) + set_target_properties(llrendervulkanmaterialrenderpass PROPERTIES FOLDER "Tests") + + set_source_files_properties( + tests/llrendervulkanmaterialrenderpass_test.cpp + PROPERTIES SKIP_PRECOMPILE_HEADERS TRUE) + LL_ADD_INTEGRATION_TEST( + llrendervulkanmaterialrenderpass + "" + "llrendervulkanmaterialrenderpass;llrendervulkanmaterialattachment;llrendercontract;llcommon") + target_compile_definitions( + INTEGRATION_TEST_llrendervulkanmaterialrenderpass + PRIVATE VK_NO_PROTOTYPES) + + set(llvulkan_texture_upload_vertex_source + "${llvulkan_shader_source_dir}/textureupload.vert.glsl") + set(llvulkan_texture_upload_fragment_source + "${llvulkan_shader_source_dir}/textureupload.frag.glsl") + set(llvulkan_texture_upload_vertex_spv + "${llvulkan_shader_binary_dir}/textureupload.vert.spv") + set(llvulkan_texture_upload_fragment_spv + "${llvulkan_shader_binary_dir}/textureupload.frag.spv") + + add_custom_command( + OUTPUT "${llvulkan_texture_upload_vertex_spv}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${llvulkan_shader_binary_dir}" + COMMAND "${CMAKE_COMMAND}" -E remove -f "${llvulkan_texture_upload_vertex_spv}.tmp" + COMMAND "${LL_VULKAN_GLSLANG_VALIDATOR}" + -V --target-env vulkan1.1 -S vert + "-I${llvulkan_shader_source_dir}" + -o "${llvulkan_texture_upload_vertex_spv}.tmp" + "${llvulkan_texture_upload_vertex_source}" + COMMAND "${LL_VULKAN_SPIRV_VAL}" + --target-env vulkan1.1 "${llvulkan_texture_upload_vertex_spv}.tmp" + COMMAND "${CMAKE_COMMAND}" -E rename + "${llvulkan_texture_upload_vertex_spv}.tmp" + "${llvulkan_texture_upload_vertex_spv}" + DEPENDS + "${llvulkan_texture_upload_vertex_source}" + "${CMAKE_CURRENT_SOURCE_DIR}/../newview/app_settings/shaders/class1/interface/copyV.glsl" + COMMENT "Compiling and validating Vulkan texture upload vertex shader" + VERBATIM + ) + + add_custom_command( + OUTPUT "${llvulkan_texture_upload_fragment_spv}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${llvulkan_shader_binary_dir}" + COMMAND "${CMAKE_COMMAND}" -E remove -f "${llvulkan_texture_upload_fragment_spv}.tmp" + COMMAND "${LL_VULKAN_GLSLANG_VALIDATOR}" + -V --target-env vulkan1.1 -S frag + "-I${llvulkan_shader_source_dir}" + -o "${llvulkan_texture_upload_fragment_spv}.tmp" + "${llvulkan_texture_upload_fragment_source}" + COMMAND "${LL_VULKAN_SPIRV_VAL}" + --target-env vulkan1.1 "${llvulkan_texture_upload_fragment_spv}.tmp" + COMMAND "${CMAKE_COMMAND}" -E rename + "${llvulkan_texture_upload_fragment_spv}.tmp" + "${llvulkan_texture_upload_fragment_spv}" + DEPENDS + "${llvulkan_texture_upload_fragment_source}" + "${CMAKE_CURRENT_SOURCE_DIR}/../newview/app_settings/shaders/class1/interface/copyF.glsl" + COMMENT "Compiling and validating Vulkan texture upload fragment shader" + VERBATIM + ) + + add_custom_target(llvulkantextureupload_shaders + DEPENDS + "${llvulkan_texture_upload_vertex_spv}" + "${llvulkan_texture_upload_fragment_spv}") + set_target_properties(llvulkantextureupload_shaders PROPERTIES FOLDER "Tests") + + set(llvulkan_link_library Vulkan::Vulkan) + set(llvulkan_universal_macos OFF) + if (DARWIN) + if (NOT LL_VULKAN_MOLTENVK_ICD OR + NOT EXISTS "${LL_VULKAN_MOLTENVK_ICD}") + message(FATAL_ERROR + "LL_VULKAN_TONEMAP_TEST on macOS requires LL_VULKAN_MOLTENVK_ICD to name a MoltenVK ICD manifest") + endif () + + list(FIND CMAKE_OSX_ARCHITECTURES "arm64" llvulkan_arm64_index) + list(FIND CMAKE_OSX_ARCHITECTURES "x86_64" llvulkan_x86_64_index) + if (NOT llvulkan_arm64_index EQUAL -1 AND + NOT llvulkan_x86_64_index EQUAL -1) + if (NOT LL_VULKAN_MACOS_X86_64_LOADER OR + NOT EXISTS "${LL_VULKAN_MACOS_X86_64_LOADER}") + message(FATAL_ERROR + "A universal LL_VULKAN_TONEMAP_TEST requires LL_VULKAN_MACOS_X86_64_LOADER") + endif () + if (NOT Vulkan_LIBRARY OR NOT EXISTS "${Vulkan_LIBRARY}") + message(FATAL_ERROR + "A universal LL_VULKAN_TONEMAP_TEST requires Vulkan_LIBRARY to name the host Vulkan loader dylib") + endif () + find_program(LL_VULKAN_LIPO NAMES lipo DOC "Path to Apple's lipo tool") + if (NOT LL_VULKAN_LIPO OR NOT EXISTS "${LL_VULKAN_LIPO}") + message(FATAL_ERROR "A universal LL_VULKAN_TONEMAP_TEST requires lipo") + endif () + find_program(LL_VULKAN_INSTALL_NAME_TOOL + NAMES install_name_tool + DOC "Path to Apple's install_name_tool") + if (NOT LL_VULKAN_INSTALL_NAME_TOOL OR + NOT EXISTS "${LL_VULKAN_INSTALL_NAME_TOOL}") + message(FATAL_ERROR + "A universal LL_VULKAN_TONEMAP_TEST requires install_name_tool") + endif () + find_program(LL_VULKAN_CODESIGN + NAMES codesign + DOC "Path to Apple's codesign tool") + if (NOT LL_VULKAN_CODESIGN OR NOT EXISTS "${LL_VULKAN_CODESIGN}") + message(FATAL_ERROR + "A universal LL_VULKAN_TONEMAP_TEST requires codesign") + endif () + + set(llvulkan_fat_loader_dir "${CMAKE_CURRENT_BINARY_DIR}/vulkan/lib") + set(llvulkan_fat_loader "${llvulkan_fat_loader_dir}/libvulkan.1.dylib") + add_custom_command( + OUTPUT "${llvulkan_fat_loader}" + COMMAND "${CMAKE_COMMAND}" -E make_directory "${llvulkan_fat_loader_dir}" + COMMAND "${CMAKE_COMMAND}" -E remove -f "${llvulkan_fat_loader}.tmp" + COMMAND "${LL_VULKAN_LIPO}" -create + "${Vulkan_LIBRARY}" + "${LL_VULKAN_MACOS_X86_64_LOADER}" + -output "${llvulkan_fat_loader}.tmp" + COMMAND "${LL_VULKAN_INSTALL_NAME_TOOL}" + -id "@rpath/libvulkan.1.dylib" + "${llvulkan_fat_loader}.tmp" + COMMAND "${LL_VULKAN_CODESIGN}" --force --sign - + "${llvulkan_fat_loader}.tmp" + COMMAND "${CMAKE_COMMAND}" -E rename + "${llvulkan_fat_loader}.tmp" "${llvulkan_fat_loader}" + DEPENDS "${Vulkan_LIBRARY}" "${LL_VULKAN_MACOS_X86_64_LOADER}" + COMMENT "Creating universal Vulkan loader for the tonemap diagnostic" + VERBATIM + ) + add_custom_target(llvulkantonemap_loader DEPENDS "${llvulkan_fat_loader}") + set_target_properties(llvulkantonemap_loader PROPERTIES FOLDER "Tests") + set(llvulkan_link_library "${llvulkan_fat_loader}") + set(llvulkan_universal_macos ON) + endif () + endif () + + add_library(llrendervulkantonemap STATIC + vulkan/llrendervulkantonemap.cpp + vulkan/llrendervulkantonemap.h + ) + target_include_directories(llrendervulkantonemap + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkantonemap SYSTEM + PUBLIC "${Vulkan_INCLUDE_DIR}") + if (llvulkan_universal_macos) + add_dependencies(llrendervulkantonemap llvulkantonemap_loader) + endif () + target_link_libraries(llrendervulkantonemap + PUBLIC + llrendercontract + "${llvulkan_link_library}") + set_target_properties(llrendervulkantonemap PROPERTIES FOLDER "Tests") + + add_executable(llvulkantonemap vulkan/llvulkantonemap_main.cpp) + target_link_libraries(llvulkantonemap PRIVATE llrendervulkantonemap) + add_dependencies(llvulkantonemap llvulkantonemap_shaders) + + add_executable(lltonemapcompare vulkan/lltonemapcompare_main.cpp) + target_link_libraries(lltonemapcompare PRIVATE llrendercontract) + + add_library(llrendervulkanmaterial STATIC + vulkan/llrendervulkanmaterial.cpp + vulkan/llrendervulkanmaterial.h + ) + target_include_directories(llrendervulkanmaterial + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkanmaterial SYSTEM + PUBLIC "${Vulkan_INCLUDE_DIR}") + if (llvulkan_universal_macos) + add_dependencies(llrendervulkanmaterial llvulkantonemap_loader) + endif () + target_link_libraries(llrendervulkanmaterial + PUBLIC + llrendercontract + "${llvulkan_link_library}") + set_target_properties(llrendervulkanmaterial PROPERTIES FOLDER "Tests") + + add_executable(llvulkanmaterial vulkan/llvulkanmaterial_main.cpp) + target_link_libraries(llvulkanmaterial + PRIVATE llrendervulkanmaterial llrendervulkanglobaldispatch) + add_dependencies(llvulkanmaterial llvulkanmaterial_shaders) + + add_executable(llmaterialcompare vulkan/llmaterialcompare_main.cpp) + target_link_libraries(llmaterialcompare PRIVATE llrendercontract) + + add_library(llrendervulkantextureupload STATIC + vulkan/llrendervulkantextureupload.cpp + vulkan/llrendervulkantextureupload.h + ) + target_include_directories(llrendervulkantextureupload + PUBLIC "${CMAKE_CURRENT_SOURCE_DIR}/vulkan") + target_include_directories(llrendervulkantextureupload SYSTEM + PUBLIC "${Vulkan_INCLUDE_DIR}") + if (llvulkan_universal_macos) + add_dependencies(llrendervulkantextureupload llvulkantonemap_loader) + endif () + target_link_libraries(llrendervulkantextureupload + PUBLIC + llrendercontract + "${llvulkan_link_library}") + set_target_properties(llrendervulkantextureupload PROPERTIES FOLDER "Tests") + + add_executable(llvulkantextureupload vulkan/llvulkantextureupload_main.cpp) + target_link_libraries(llvulkantextureupload PRIVATE llrendervulkantextureupload) + add_dependencies(llvulkantextureupload llvulkantextureupload_shaders) + + add_executable(lltextureuploadcompare vulkan/lltextureuploadcompare_main.cpp) + target_link_libraries(lltextureuploadcompare PRIVATE llrendercontract) + + if (DARWIN) + set_target_properties( + llvulkantonemap lltonemapcompare llvulkanmaterial llmaterialcompare + llvulkantextureupload lltextureuploadcompare + PROPERTIES + OSX_ARCHITECTURES "${CMAKE_OSX_ARCHITECTURES}" + XCODE_ATTRIBUTE_CODE_SIGN_IDENTITY "-" + XCODE_ATTRIBUTE_CODE_SIGNING_ALLOWED "YES" + FOLDER "Tests") + if (llvulkan_universal_macos) + set_target_properties(llvulkantonemap llvulkanmaterial llvulkantextureupload PROPERTIES + BUILD_RPATH "${llvulkan_fat_loader_dir}") + endif () + else () + set_target_properties( + llvulkantonemap lltonemapcompare llvulkanmaterial llmaterialcompare + llvulkantextureupload lltextureuploadcompare + PROPERTIES + FOLDER "Tests") + endif () + + set_source_files_properties( + tests/llrendervulkanmaterialregistry_test.cpp + tests/llrendervulkantextureuploadregistry_test.cpp + tests/llrendervulkantonemapregistry_test.cpp + PROPERTIES + SKIP_PRECOMPILE_HEADERS TRUE + ) + LL_ADD_INTEGRATION_TEST( + llrendervulkantonemapregistry + "" + "llrendervulkantonemap;${llvulkan_link_library};llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST( + llrendervulkanmaterialregistry + "" + "llrendervulkanmaterial;${llvulkan_link_library};llrendercontract;llcommon") + LL_ADD_INTEGRATION_TEST( + llrendervulkantextureuploadregistry + "" + "llrendervulkantextureupload;${llvulkan_link_library};llrendercontract;llcommon") + if (llvulkan_universal_macos) + set_target_properties( + INTEGRATION_TEST_llrendervulkantonemapregistry + INTEGRATION_TEST_llrendervulkanmaterialregistry + INTEGRATION_TEST_llrendervulkantextureuploadregistry + PROPERTIES + BUILD_WITH_INSTALL_RPATH OFF + BUILD_RPATH "${llvulkan_fat_loader_dir}") + endif () + + add_dependencies( + BUILD_TESTS + llvulkantonemap + lltonemapcompare + llvulkanmaterial + llmaterialcompare + llvulkantextureupload + lltextureuploadcompare) +endif () diff --git a/indra/llrender/lldrawpacketcontract.cpp b/indra/llrender/lldrawpacketcontract.cpp new file mode 100644 index 00000000000..7fb3abb3ba1 --- /dev/null +++ b/indra/llrender/lldrawpacketcontract.cpp @@ -0,0 +1,286 @@ +/** + * @file lldrawpacketcontract.cpp + * @brief Builder and validation for one prepared legacy material draw. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "lldrawpacketcontract.h" + +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + + bool validIndexType(IndexType type) noexcept + { + switch (type) + { + case IndexType::UInt16: + case IndexType::UInt32: + return true; + } + return false; + } + + bool validSubresourceRange(const ImageSubresourceRange& range) noexcept + { + return range.mMipLevelCount != 0 && range.mArrayLayerCount != 0 && + range.mBaseMipLevel <= std::numeric_limits::max() - range.mMipLevelCount && + range.mBaseArrayLayer <= std::numeric_limits::max() - range.mArrayLayerCount; + } + + bool validDescriptor(const DrawTextureInput& descriptor) noexcept + { + return static_cast(descriptor.mImage) && static_cast(descriptor.mSampler) && validSubresourceRange(descriptor.mRange); + } + + bool sameColorTarget(const ColorTargetState& left, const ColorTargetState& right) noexcept + { + return left.mFormat == right.mFormat && left.mBlendEnabled == right.mBlendEnabled && left.mWriteMask == right.mWriteMask; + } + + bool sameColorTargets(const std::vector& targets, std::initializer_list formats) noexcept + { + if (targets.size() != formats.size()) + { + return false; + } + + return std::equal(targets.begin(), targets.end(), formats.begin(), [](const ColorTargetState& target, PixelFormat format) + { return sameColorTarget(target, { format, false, 0xf }); }); + } + + LegacyNormSpecPipelineKey pipelineKey(LegacyNormSpecShaderVariant variant, LegacyNormSpecTargetProfile profile, + std::initializer_list formats) + { + LegacyNormSpecPipelineKey key; + key.mProgram = variant == LEGACY_NORMSPEC_DIAGNOSTIC_SHADER_VARIANT ? legacyNormSpecDiagnosticProgramKey() + : legacyNormSpecProductionProgramKey(); + key.mShaderVariant = variant; + key.mTargetProfile = profile; + key.mVertexLayout = DrawVertexLayout::LegacyMaterialNormSpec; + key.mTopology = PrimitiveTopology::TriangleList; + key.mCullMode = CullMode::Back; + key.mFrontFace = FrontFace::CounterClockwise; + key.mDepthTestEnabled = true; + key.mDepthWriteEnabled = true; + key.mDepthCompare = CompareOp::LessOrEqual; + key.mSamples = 1; + key.mColorTargets.reserve(formats.size()); + std::transform(formats.begin(), formats.end(), std::back_inserter(key.mColorTargets), + [](PixelFormat format) { return ColorTargetState{ format, false, 0xf }; }); + key.mDepthFormat = PixelFormat::Depth24Unorm; + return key; + } + + bool finite(const DrawMatrix4& matrix) noexcept + { + return std::all_of(matrix.begin(), matrix.end(), [](float value) { return std::isfinite(value); }); + } + + bool unitRange(float value) noexcept + { + return std::isfinite(value) && value >= 0.f && value <= 1.f; + } + + bool validHandles(const DrawPacketHandles& handles) noexcept + { + return static_cast(handles.mVertexBuffer) && static_cast(handles.mIndexBuffer) && + handles.mVertexBuffer != handles.mIndexBuffer && static_cast(handles.mPipeline); + } + + bool validRanges(const LegacyNormSpecDrawPacket& packet) noexcept + { + return packet.mIndexCount != 0 && packet.mFirstIndex <= std::numeric_limits::max() - packet.mIndexCount && + packet.mMinVertex <= packet.mMaxVertex; + } + + bool validConstants(const LegacyNormSpecDrawPacket& packet) noexcept + { + return finite(packet.mModelMatrix) && finite(packet.mDiffuseTextureMatrix) && + std::all_of(packet.mSpecularRGBA.begin(), packet.mSpecularRGBA.end(), unitRange) && + unitRange(packet.mEnvironmentIntensity) && unitRange(packet.mAlphaCutoff) && + (packet.mEmissiveBrightness == 0.f || packet.mEmissiveBrightness == 1.f); + } + +} // namespace + +bool validLegacyNormSpecShaderVariant(LegacyNormSpecShaderVariant variant) noexcept +{ + switch (variant.mEmissive) + { + case LegacyNormSpecEmissive::Disabled: + case LegacyNormSpecEmissive::Enabled: + break; + default: + return false; + } + + switch (variant.mShadowAssembly) + { + case ShadowAssembly::Disabled: + case ShadowAssembly::Sun: + case ShadowAssembly::SunAndSpot: + return true; + } + return false; +} + +std::optional encodeLegacyNormSpecShaderVariant(LegacyNormSpecShaderVariant variant) noexcept +{ + if (!validLegacyNormSpecShaderVariant(variant)) + { + return std::nullopt; + } + + return static_cast(variant.mEmissive) | (static_cast(variant.mShadowAssembly) << 1); +} + +std::optional decodeLegacyNormSpecShaderVariant(std::uint64_t encoded) noexcept +{ + constexpr std::uint64_t KNOWN_BITS = 0x7; + if ((encoded & ~KNOWN_BITS) != 0) + { + return std::nullopt; + } + + const auto variant = LegacyNormSpecShaderVariant{ static_cast(encoded & 0x1), + static_cast((encoded >> 1) & 0x3) }; + if (!validLegacyNormSpecShaderVariant(variant)) + { + return std::nullopt; + } + return variant; +} + +ShaderProgramKey legacyNormSpecDiagnosticProgramKey() +{ + return { LEGACY_NORMSPEC_PIPELINE_NAME, LEGACY_NORMSPEC_DIAGNOSTIC_VARIANT }; +} + +ShaderProgramKey legacyNormSpecProductionProgramKey() +{ + return { LEGACY_NORMSPEC_PIPELINE_NAME, LEGACY_NORMSPEC_PRODUCTION_VARIANT }; +} + +LegacyNormSpecPipelineKey legacyNormSpecDiagnosticPipelineKey() +{ + return pipelineKey(LEGACY_NORMSPEC_DIAGNOSTIC_SHADER_VARIANT, LegacyNormSpecTargetProfile::DiagnosticThreeTarget, + { PixelFormat::RGBA8Unorm, PixelFormat::RGBA8Unorm, PixelFormat::RGBA16Unorm }); +} + +LegacyNormSpecPipelineKey legacyNormSpecModernHDRPipelineKey() +{ + return pipelineKey(LEGACY_NORMSPEC_PRODUCTION_SHADER_VARIANT, LegacyNormSpecTargetProfile::ModernHDR, + { PixelFormat::RGBA8Unorm, PixelFormat::RGBA8Unorm, PixelFormat::RGBA16Unorm, PixelFormat::RGB16Float }); +} + +LegacyNormSpecPipelineKey legacyNormSpecCompatibilityPipelineKey() +{ + return pipelineKey(LEGACY_NORMSPEC_PRODUCTION_SHADER_VARIANT, LegacyNormSpecTargetProfile::Compatibility, + { PixelFormat::RGBA8Unorm, PixelFormat::RGBA8Unorm, PixelFormat::RGB10A2Unorm, PixelFormat::RGB8Unorm }); +} + +bool validLegacyNormSpecPipelineKey(const LegacyNormSpecPipelineKey& key) noexcept +{ + const auto decoded_variant = decodeLegacyNormSpecShaderVariant(key.mProgram.mVariant); + if (key.mProgram.mName != LEGACY_NORMSPEC_PIPELINE_NAME || !decoded_variant || *decoded_variant != key.mShaderVariant || + key.mVertexLayout != DrawVertexLayout::LegacyMaterialNormSpec || key.mTopology != PrimitiveTopology::TriangleList || + key.mCullMode != CullMode::Back || key.mFrontFace != FrontFace::CounterClockwise || !key.mDepthTestEnabled || + !key.mDepthWriteEnabled || key.mDepthCompare != CompareOp::LessOrEqual || key.mSamples != 1 || + key.mDepthFormat != PixelFormat::Depth24Unorm) + { + return false; + } + + switch (key.mTargetProfile) + { + case LegacyNormSpecTargetProfile::DiagnosticThreeTarget: + return key.mShaderVariant == LEGACY_NORMSPEC_DIAGNOSTIC_SHADER_VARIANT && + sameColorTargets(key.mColorTargets, { PixelFormat::RGBA8Unorm, PixelFormat::RGBA8Unorm, PixelFormat::RGBA16Unorm }); + case LegacyNormSpecTargetProfile::ModernHDR: + return key.mShaderVariant == LEGACY_NORMSPEC_PRODUCTION_SHADER_VARIANT && + sameColorTargets(key.mColorTargets, { PixelFormat::RGBA8Unorm, PixelFormat::RGBA8Unorm, PixelFormat::RGBA16Unorm, + PixelFormat::RGB16Float }); + case LegacyNormSpecTargetProfile::Compatibility: + return key.mShaderVariant == LEGACY_NORMSPEC_PRODUCTION_SHADER_VARIANT && + sameColorTargets(key.mColorTargets, { PixelFormat::RGBA8Unorm, PixelFormat::RGBA8Unorm, PixelFormat::RGB10A2Unorm, + PixelFormat::RGB8Unorm }); + } + return false; +} + +bool operator==(const LegacyNormSpecPipelineKey& left, const LegacyNormSpecPipelineKey& right) +{ + return left.mProgram.mName == right.mProgram.mName && left.mProgram.mVariant == right.mProgram.mVariant && + left.mShaderVariant == right.mShaderVariant && left.mTargetProfile == right.mTargetProfile && + left.mVertexLayout == right.mVertexLayout && left.mTopology == right.mTopology && left.mCullMode == right.mCullMode && + left.mFrontFace == right.mFrontFace && left.mDepthTestEnabled == right.mDepthTestEnabled && + left.mDepthWriteEnabled == right.mDepthWriteEnabled && left.mDepthCompare == right.mDepthCompare && + left.mSamples == right.mSamples && left.mDepthFormat == right.mDepthFormat && + left.mColorTargets.size() == right.mColorTargets.size() && + std::equal(left.mColorTargets.begin(), left.mColorTargets.end(), right.mColorTargets.begin(), sameColorTarget); +} + +bool operator==(const LegacyNormSpecDrawPacket& left, const LegacyNormSpecDrawPacket& right) +{ + return left.mFrame == right.mFrame && left.mPass == right.mPass && left.mHandles == right.mHandles && + left.mDescriptors == right.mDescriptors && left.mPipelineKey == right.mPipelineKey && left.mIndexType == right.mIndexType && + left.mFirstIndex == right.mFirstIndex && left.mIndexCount == right.mIndexCount && left.mMinVertex == right.mMinVertex && + left.mMaxVertex == right.mMaxVertex && left.mModelMatrix == right.mModelMatrix && + left.mDiffuseTextureMatrix == right.mDiffuseTextureMatrix && left.mSpecularRGBA == right.mSpecularRGBA && + left.mEnvironmentIntensity == right.mEnvironmentIntensity && left.mAlphaCutoff == right.mAlphaCutoff && + left.mEmissiveBrightness == right.mEmissiveBrightness; +} + +bool validLegacyNormSpecDrawPacket(const LegacyNormSpecDrawPacket& packet) noexcept +{ + return packet.mFrame != 0 && static_cast(packet.mPass) && validHandles(packet.mHandles) && + validDescriptor(packet.mDescriptors.mDiffuse) && validDescriptor(packet.mDescriptors.mNormal) && + validDescriptor(packet.mDescriptors.mSpecular) && validLegacyNormSpecPipelineKey(packet.mPipelineKey) && + validIndexType(packet.mIndexType) && validRanges(packet) && validConstants(packet); +} + +std::optional buildLegacyNormSpecDrawPacket(const LegacyNormSpecDrawInputs& inputs) +{ + LegacyNormSpecDrawPacket packet; + packet.mFrame = inputs.mFrame; + packet.mPass = inputs.mPass; + packet.mHandles = inputs.mHandles; + packet.mDescriptors = inputs.mDescriptors; + packet.mPipelineKey = inputs.mPipelineKey; + packet.mIndexType = inputs.mIndexType; + packet.mFirstIndex = inputs.mFirstIndex; + packet.mIndexCount = inputs.mIndexCount; + packet.mMinVertex = inputs.mMinVertex; + packet.mMaxVertex = inputs.mMaxVertex; + packet.mModelMatrix = inputs.mModelMatrix; + packet.mDiffuseTextureMatrix = inputs.mDiffuseTextureMatrix; + packet.mSpecularRGBA = inputs.mSpecularRGBA; + packet.mEnvironmentIntensity = inputs.mEnvironmentIntensity; + packet.mAlphaCutoff = inputs.mAlphaCutoff; + packet.mEmissiveBrightness = inputs.mEmissiveBrightness; + + if (!validLegacyNormSpecDrawPacket(packet)) + { + return std::nullopt; + } + return packet; +} + +} // namespace LLRenderContract diff --git a/indra/llrender/lldrawpacketcontract.h b/indra/llrender/lldrawpacketcontract.h new file mode 100644 index 00000000000..efcd2c5247b --- /dev/null +++ b/indra/llrender/lldrawpacketcontract.h @@ -0,0 +1,187 @@ +/** + * @file lldrawpacketcontract.h + * @brief Owned API-neutral packet for one prepared legacy material draw. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLDRAWPACKETCONTRACT_H +#define LL_LLDRAWPACKETCONTRACT_H + +#include "llrendercontract.h" + +#include +#include +#include +#include + +namespace LLRenderContract +{ + +inline constexpr char LEGACY_NORMSPEC_PIPELINE_NAME[] = "deferred.material.normspec"; + +using DrawMatrix4 = std::array; + +inline constexpr DrawMatrix4 DRAW_IDENTITY_MATRIX4{ 1.f, 0.f, 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 0.f, 1.f }; + +struct DrawPacketHandles +{ + BufferHandle mVertexBuffer; + BufferHandle mIndexBuffer; + PipelineHandle mPipeline; + + friend constexpr bool operator==(const DrawPacketHandles&, const DrawPacketHandles&) = default; +}; + +struct DrawTextureInput +{ + ImageHandle mImage; + SamplerHandle mSampler; + ImageSubresourceRange mRange; + + friend constexpr bool operator==(const DrawTextureInput&, const DrawTextureInput&) = default; +}; + +struct LegacyNormSpecDescriptors +{ + DrawTextureInput mDiffuse; + DrawTextureInput mNormal; + DrawTextureInput mSpecular; + + friend constexpr bool operator==(const LegacyNormSpecDescriptors&, const LegacyNormSpecDescriptors&) = default; +}; + +enum class DrawVertexLayout : std::uint8_t +{ + LegacyMaterialNormSpec +}; + +enum class LegacyNormSpecEmissive : std::uint8_t +{ + Disabled = 0, + Enabled = 1 +}; + +enum class ShadowAssembly : std::uint8_t +{ + Disabled = 0, + Sun = 1, + SunAndSpot = 2 +}; + +struct LegacyNormSpecShaderVariant +{ + LegacyNormSpecEmissive mEmissive = LegacyNormSpecEmissive::Disabled; + ShadowAssembly mShadowAssembly = ShadowAssembly::Disabled; + + friend constexpr bool operator==(const LegacyNormSpecShaderVariant&, const LegacyNormSpecShaderVariant&) = default; +}; + +inline constexpr LegacyNormSpecShaderVariant LEGACY_NORMSPEC_DIAGNOSTIC_SHADER_VARIANT{ LegacyNormSpecEmissive::Disabled, + ShadowAssembly::Disabled }; +inline constexpr LegacyNormSpecShaderVariant LEGACY_NORMSPEC_PRODUCTION_SHADER_VARIANT{ LegacyNormSpecEmissive::Enabled, + ShadowAssembly::SunAndSpot }; +inline constexpr std::uint64_t LEGACY_NORMSPEC_DIAGNOSTIC_VARIANT = + static_cast(LegacyNormSpecEmissive::Disabled) | (static_cast(ShadowAssembly::Disabled) << 1); +inline constexpr std::uint64_t LEGACY_NORMSPEC_PRODUCTION_VARIANT = + static_cast(LegacyNormSpecEmissive::Enabled) | (static_cast(ShadowAssembly::SunAndSpot) << 1); + +enum class LegacyNormSpecTargetProfile : std::uint8_t +{ + DiagnosticThreeTarget, + ModernHDR, + Compatibility +}; + +struct LegacyNormSpecPipelineKey +{ + ShaderProgramKey mProgram{ LEGACY_NORMSPEC_PIPELINE_NAME, LEGACY_NORMSPEC_DIAGNOSTIC_VARIANT }; + LegacyNormSpecShaderVariant mShaderVariant = LEGACY_NORMSPEC_DIAGNOSTIC_SHADER_VARIANT; + LegacyNormSpecTargetProfile mTargetProfile = LegacyNormSpecTargetProfile::DiagnosticThreeTarget; + DrawVertexLayout mVertexLayout = DrawVertexLayout::LegacyMaterialNormSpec; + PrimitiveTopology mTopology = PrimitiveTopology::TriangleList; + CullMode mCullMode = CullMode::Back; + FrontFace mFrontFace = FrontFace::CounterClockwise; + bool mDepthTestEnabled = false; + bool mDepthWriteEnabled = false; + CompareOp mDepthCompare = CompareOp::LessOrEqual; + std::uint32_t mSamples = 1; + std::vector mColorTargets; + std::optional mDepthFormat; + + friend bool operator==(const LegacyNormSpecPipelineKey& left, const LegacyNormSpecPipelineKey& right); + friend bool operator!=(const LegacyNormSpecPipelineKey& left, const LegacyNormSpecPipelineKey& right) { return !(left == right); } +}; + +struct LegacyNormSpecDrawInputs +{ + std::uint64_t mFrame = 0; + PassId mPass; + DrawPacketHandles mHandles; + LegacyNormSpecDescriptors mDescriptors; + LegacyNormSpecPipelineKey mPipelineKey; + IndexType mIndexType = IndexType::UInt16; + std::uint32_t mFirstIndex = 0; + std::uint32_t mIndexCount = 0; + std::uint32_t mMinVertex = 0; + std::uint32_t mMaxVertex = 0; + DrawMatrix4 mModelMatrix = DRAW_IDENTITY_MATRIX4; + DrawMatrix4 mDiffuseTextureMatrix = DRAW_IDENTITY_MATRIX4; + std::array mSpecularRGBA{ 1.f, 1.f, 1.f, 0.5f }; + float mEnvironmentIntensity = 0.f; + float mAlphaCutoff = 0.5f; + float mEmissiveBrightness = 0.f; +}; + +struct LegacyNormSpecDrawPacket +{ + std::uint64_t mFrame = 0; + PassId mPass; + DrawPacketHandles mHandles; + LegacyNormSpecDescriptors mDescriptors; + LegacyNormSpecPipelineKey mPipelineKey; + IndexType mIndexType = IndexType::UInt16; + std::uint32_t mFirstIndex = 0; + std::uint32_t mIndexCount = 0; + std::uint32_t mMinVertex = 0; + std::uint32_t mMaxVertex = 0; + DrawMatrix4 mModelMatrix = DRAW_IDENTITY_MATRIX4; + DrawMatrix4 mDiffuseTextureMatrix = DRAW_IDENTITY_MATRIX4; + std::array mSpecularRGBA{ 1.f, 1.f, 1.f, 0.5f }; + float mEnvironmentIntensity = 0.f; + float mAlphaCutoff = 0.5f; + float mEmissiveBrightness = 0.f; + + friend bool operator==(const LegacyNormSpecDrawPacket& left, const LegacyNormSpecDrawPacket& right); + friend bool operator!=(const LegacyNormSpecDrawPacket& left, const LegacyNormSpecDrawPacket& right) { return !(left == right); } +}; + +bool validLegacyNormSpecShaderVariant(LegacyNormSpecShaderVariant variant) noexcept; + +std::optional encodeLegacyNormSpecShaderVariant(LegacyNormSpecShaderVariant variant) noexcept; +std::optional decodeLegacyNormSpecShaderVariant(std::uint64_t encoded) noexcept; + +ShaderProgramKey legacyNormSpecDiagnosticProgramKey(); +ShaderProgramKey legacyNormSpecProductionProgramKey(); + +LegacyNormSpecPipelineKey legacyNormSpecDiagnosticPipelineKey(); +LegacyNormSpecPipelineKey legacyNormSpecModernHDRPipelineKey(); +LegacyNormSpecPipelineKey legacyNormSpecCompatibilityPipelineKey(); + +bool validLegacyNormSpecPipelineKey(const LegacyNormSpecPipelineKey& key) noexcept; + +bool validLegacyNormSpecDrawPacket(const LegacyNormSpecDrawPacket& packet) noexcept; + +std::optional buildLegacyNormSpecDrawPacket(const LegacyNormSpecDrawInputs& inputs); + +} // namespace LLRenderContract + +#endif // LL_LLDRAWPACKETCONTRACT_H diff --git a/indra/llrender/llglslshader.cpp b/indra/llrender/llglslshader.cpp index a268ea07bb4..1bacb38a0d0 100644 --- a/indra/llrender/llglslshader.cpp +++ b/indra/llrender/llglslshader.cpp @@ -66,6 +66,13 @@ U64 LLGLSLShader::sTotalSamplesDrawn = 0; U32 LLGLSLShader::sTotalBinds = 0; boost::json::value LLGLSLShader::sDefaultStats; +namespace +{ +#if defined(LL_RENDER_BENCHMARK) +U64 sShaderBindCount = 0; +#endif +} + //UI shader -- declared here so llui_libtest will link properly LLGLSLShader gUIProgram; LLGLSLShader gSolidColorProgram; @@ -245,6 +252,15 @@ void LLGLSLShader::stopProfile() } } +U64 LLGLSLShader::getShaderBindCount() +{ +#if defined(LL_RENDER_BENCHMARK) + return sShaderBindCount; +#else + return 0; +#endif +} + void LLGLSLShader::placeProfileQuery(bool for_runtime) { if (sProfileEnabled || for_runtime) @@ -1062,6 +1078,9 @@ void LLGLSLShader::bind() } LLVertexBuffer::unbind(); glUseProgram(mProgramObject); +#if defined(LL_RENDER_BENCHMARK) + ++sShaderBindCount; +#endif sCurBoundShader = mProgramObject; sCurBoundShaderPtr = this; placeProfileQuery(); diff --git a/indra/llrender/llglslshader.h b/indra/llrender/llglslshader.h index 272a99aaa58..033b154ee2c 100644 --- a/indra/llrender/llglslshader.h +++ b/indra/llrender/llglslshader.h @@ -177,6 +177,7 @@ class LLGLSLShader static void startProfile(); static void stopProfile(); + static U64 getShaderBindCount(); void unload(); void clearStats(); diff --git a/indra/llrender/llimagegl.cpp b/indra/llrender/llimagegl.cpp index 81743713a1a..e25e4ef64ed 100644 --- a/indra/llrender/llimagegl.cpp +++ b/indra/llrender/llimagegl.cpp @@ -41,6 +41,11 @@ #include "llrender.h" #include "llwindow.h" #include "llframetimer.h" + +#include +#if defined(LL_RENDER_BENCHMARK) +#include +#endif #include extern LL_COMMON_API bool on_main_thread(); @@ -65,6 +70,14 @@ U32 LLImageGL::sFrameCount = 0; static LLMutex sTexMemMutex; static std::unordered_map sTextureAllocs; static U64 sTextureBytes = 0; +#if defined(LL_RENDER_BENCHMARK) +static std::atomic sTextureUploadCount{0}; +static std::atomic sTextureUploadBytes{0}; +static std::atomic sTextureReadbackCount{0}; +static std::atomic sTextureReadbackTimeUS{0}; +static std::atomic sTextureWaitCount{0}; +static std::atomic sTextureWaitTimeUS{0}; +#endif // track a texture alloc on the currently bound texture. // asserts that no currently tracked alloc exists @@ -132,6 +145,60 @@ U64 LLImageGL::getTextureBytesAllocated() return sTextureBytes; } +U64 LLImageGL::getTextureUploadCount() +{ +#if defined(LL_RENDER_BENCHMARK) + return sTextureUploadCount.load(std::memory_order_relaxed); +#else + return 0; +#endif +} + +U64 LLImageGL::getTextureUploadBytes() +{ +#if defined(LL_RENDER_BENCHMARK) + return sTextureUploadBytes.load(std::memory_order_relaxed); +#else + return 0; +#endif +} + +U64 LLImageGL::getTextureReadbackCount() +{ +#if defined(LL_RENDER_BENCHMARK) + return sTextureReadbackCount.load(std::memory_order_relaxed); +#else + return 0; +#endif +} + +U64 LLImageGL::getTextureReadbackTimeUS() +{ +#if defined(LL_RENDER_BENCHMARK) + return sTextureReadbackTimeUS.load(std::memory_order_relaxed); +#else + return 0; +#endif +} + +U64 LLImageGL::getTextureWaitCount() +{ +#if defined(LL_RENDER_BENCHMARK) + return sTextureWaitCount.load(std::memory_order_relaxed); +#else + return 0; +#endif +} + +U64 LLImageGL::getTextureWaitTimeUS() +{ +#if defined(LL_RENDER_BENCHMARK) + return sTextureWaitTimeUS.load(std::memory_order_relaxed); +#else + return 0; +#endif +} + //statics U32 LLImageGL::sUniqueCount = 0; @@ -744,6 +811,18 @@ bool LLImageGL::setImage(const U8* data_in, bool data_hasmips /* = false */, S32 { LL_PROFILE_ZONE_SCOPED_CATEGORY_TEXTURE; +#if defined(LL_RENDER_BENCHMARK) + U64 upload_bytes = 0; + if (data_in) + { + const S32 last_discard = data_hasmips ? mMaxDiscardLevel : mCurrentDiscardLevel; + for (S32 discard = mCurrentDiscardLevel; discard <= last_discard; ++discard) + { + upload_bytes += dataFormatBytes(mFormatPrimary, getWidth(discard), getHeight(discard)); + } + } +#endif + const bool is_compressed = isCompressed(); if (mUseMipMaps) @@ -1014,6 +1093,13 @@ bool LLImageGL::setImage(const U8* data_in, bool data_hasmips /* = false */, S32 } stop_glerror(); mGLTextureCreated = true; +#if defined(LL_RENDER_BENCHMARK) + if (data_in) + { + sTextureUploadCount.fetch_add(1, std::memory_order_relaxed); + sTextureUploadBytes.fetch_add(upload_bytes, std::memory_order_relaxed); + } +#endif return true; } @@ -1216,6 +1302,11 @@ bool LLImageGL::setSubImage(const U8* datap, S32 data_width, S32 data_height, S3 glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); stop_glerror(); mGLTextureCreated = true; + +#if defined(LL_RENDER_BENCHMARK) + sTextureUploadCount.fetch_add(1, std::memory_order_relaxed); + sTextureUploadBytes.fetch_add(dataFormatBytes(mFormatPrimary, width, height), std::memory_order_relaxed); +#endif } return true; } @@ -1745,7 +1836,17 @@ void LLImageGL::syncToMainThread(LLGLuint new_tex_name) // upload is complete auto sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); glFlush(); +#if defined(LL_RENDER_BENCHMARK) + const auto wait_start = std::chrono::steady_clock::now(); +#endif glClientWaitSync(sync, 0, GL_TIMEOUT_IGNORED); +#if defined(LL_RENDER_BENCHMARK) + const auto wait_end = std::chrono::steady_clock::now(); + sTextureWaitCount.fetch_add(1, std::memory_order_relaxed); + sTextureWaitTimeUS.fetch_add( + std::chrono::duration_cast(wait_end - wait_start).count(), + std::memory_order_relaxed); +#endif glDeleteSync(sync); } else @@ -1763,7 +1864,17 @@ void LLImageGL::syncToMainThread(LLGLuint new_tex_name) LL_PROFILE_ZONE_NAMED("cglt - wait sync"); { LL_PROFILE_ZONE_NAMED("glWaitSync"); +#if defined(LL_RENDER_BENCHMARK) + const auto wait_start = std::chrono::steady_clock::now(); +#endif glWaitSync(sync, 0, GL_TIMEOUT_IGNORED); +#if defined(LL_RENDER_BENCHMARK) + const auto wait_end = std::chrono::steady_clock::now(); + sTextureWaitCount.fetch_add(1, std::memory_order_relaxed); + sTextureWaitTimeUS.fetch_add( + std::chrono::duration_cast(wait_end - wait_start).count(), + std::memory_order_relaxed); +#endif } { LL_PROFILE_ZONE_NAMED("glDeleteSync"); @@ -1866,6 +1977,9 @@ bool LLImageGL::readBackRaw(S32 discard_level, LLImageRaw* imageraw, bool compre LLImageDataLock lock(imageraw); +#if defined(LL_RENDER_BENCHMARK) + const auto readback_start = std::chrono::steady_clock::now(); +#endif if (is_compressed) { LLGLint glbytes; @@ -1912,6 +2026,14 @@ bool LLImageGL::readBackRaw(S32 discard_level, LLImageRaw* imageraw, bool compre //stop_glerror(); } +#if defined(LL_RENDER_BENCHMARK) + const auto readback_end = std::chrono::steady_clock::now(); + sTextureReadbackCount.fetch_add(1, std::memory_order_relaxed); + sTextureReadbackTimeUS.fetch_add( + std::chrono::duration_cast(readback_end - readback_start).count(), + std::memory_order_relaxed); +#endif + //----------------------------------------------------------------------------------------------- if((error = glGetError()) != GL_NO_ERROR) { @@ -2741,4 +2863,3 @@ void LLImageGLThread::run() gGL.shutdown(); mWindow->destroySharedContext(mContext); } - diff --git a/indra/llrender/llimagegl.h b/indra/llrender/llimagegl.h index 0e186ca833b..e25064d6ea5 100644 --- a/indra/llrender/llimagegl.h +++ b/indra/llrender/llimagegl.h @@ -71,6 +71,15 @@ class LLImageGL : public LLRefCount // video memory usage based on testing in lagland against an NVIDIA GPU. static U64 getTextureBytesAllocated(); + // Cumulative, process-local counters used by the renderer benchmark. They + // are cheap enough to sample once per frame and never contain image data. + static U64 getTextureUploadCount(); + static U64 getTextureUploadBytes(); + static U64 getTextureReadbackCount(); + static U64 getTextureReadbackTimeUS(); + static U64 getTextureWaitCount(); + static U64 getTextureWaitTimeUS(); + // These 2 functions replace glGenTextures() and glDeleteTextures() static void generateTextures(S32 numTextures, U32 *textures); static void deleteTextures(S32 numTextures, const U32 *textures); diff --git a/indra/llrender/llmaterialcontract.cpp b/indra/llrender/llmaterialcontract.cpp new file mode 100644 index 00000000000..b8cb3db7d8c --- /dev/null +++ b/indra/llrender/llmaterialcontract.cpp @@ -0,0 +1,443 @@ +/** + * @file llmaterialcontract.cpp + * @brief Pure builder and decoder for one indexed legacy material draw. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llmaterialcontract.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + + constexpr const char* MATERIAL_PROGRAM = "deferred.material.normspec"; + constexpr const char* MATERIAL_PASS = "indexed material"; + constexpr Extent2D MATERIAL_FRAME_EXTENT{ MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT }; + constexpr Extent2D MATERIAL_TEXTURE_EXTENT{ MATERIAL_TEXTURE_WIDTH, MATERIAL_TEXTURE_HEIGHT }; + constexpr ImageSubresourceRange MATERIAL_TEXTURE_RANGE{ 0, MATERIAL_TEXTURE_MIP_LEVELS, 0, 1 }; + + bool sameExtent(Extent2D left, Extent2D right) + { + return left.mWidth == right.mWidth && left.mHeight == right.mHeight; + } + + bool sameRange(const ImageSubresourceRange& left, const ImageSubresourceRange& right) + { + return left.mBaseMipLevel == right.mBaseMipLevel && left.mMipLevelCount == right.mMipLevelCount && + left.mBaseArrayLayer == right.mBaseArrayLayer && left.mArrayLayerCount == right.mArrayLayerCount; + } + + bool zeroClear(const ClearColor& clear) + { + return clear.mRed == 0.f && clear.mGreen == 0.f && clear.mBlue == 0.f && clear.mAlpha == 0.f; + } + + template + bool finite(const std::array& values) + { + return std::all_of(values.begin(), values.end(), [](float value) { return std::isfinite(value); }); + } + + template + bool unitRange(const std::array& values) + { + return std::all_of(values.begin(), values.end(), [](float value) { return value >= 0.f && value <= 1.f; }); + } + + template + bool distinctIndices(const std::array& handles) + { + std::array indices{}; + std::transform(handles.begin(), handles.end(), indices.begin(), [](HandleType handle) { return handle.mIndex; }); + std::sort(indices.begin(), indices.end()); + return std::adjacent_find(indices.begin(), indices.end()) == indices.end(); + } + + bool validHandles(const MaterialHandles& handles) + { + const std::array buffers{ handles.mVertexBuffer, handles.mIndexBuffer }; + const std::array images{ handles.mDiffuse, handles.mNormal, handles.mSpecular, handles.mGBuffer0, + handles.mGBuffer1, handles.mGBuffer2, handles.mDepth }; + return handles.mVertexBuffer && handles.mIndexBuffer && handles.mDiffuse && handles.mNormal && handles.mSpecular && + handles.mGBuffer0 && handles.mGBuffer1 && handles.mGBuffer2 && handles.mDepth && handles.mSampler && handles.mPipeline && + handles.mPass && distinctIndices(buffers) && distinctIndices(images); + } + + ByteRange parameterBytes(const MaterialParameters& parameters) + { + static_assert(std::is_trivially_copyable_v); + auto storage = std::make_shared>(sizeof(parameters)); + std::memcpy(storage->data(), ¶meters, sizeof(parameters)); + return { std::move(storage), 0, sizeof(parameters) }; + } + + ImageResource image(ImageHandle handle, Extent2D extent, PixelFormat format, std::uint32_t mip_levels = 1) + { + ImageResource result; + result.mHandle = handle; + result.mExtent = extent; + result.mMipLevels = mip_levels; + result.mFormat = format; + return result; + } + + bool canonicalImage(const ImageResource& resource, ImageHandle handle, Extent2D extent, PixelFormat format, + std::uint32_t mip_levels = 1) + { + return resource.mHandle == handle && sameExtent(resource.mExtent, extent) && resource.mMipLevels == mip_levels && + resource.mArrayLayers == 1 && resource.mSamples == 1 && resource.mFormat == format && + resource.mLifetime == ResourceLifetime::Persistent; + } + + SamplerResource sampler(SamplerHandle handle) + { + SamplerResource result; + result.mHandle = handle; + result.mMinFilter = Filter::Linear; + result.mMagFilter = Filter::Linear; + result.mMipFilter = MipFilter::Linear; + result.mAddressU = AddressMode::Repeat; + result.mAddressV = AddressMode::Repeat; + result.mMaxAnisotropy = 8.f; + return result; + } + + bool canonicalSampler(const SamplerResource& resource, SamplerHandle handle) + { + return resource.mHandle == handle && resource.mMinFilter == Filter::Linear && resource.mMagFilter == Filter::Linear && + resource.mMipFilter == MipFilter::Linear && resource.mAddressU == AddressMode::Repeat && + resource.mAddressV == AddressMode::Repeat && resource.mMaxAnisotropy == 8.f && + resource.mLifetime == ResourceLifetime::Persistent; + } + + bool canonicalSampledAccess(const ImageAccess& access, ImageHandle handle) + { + return access.mImage == handle && sameRange(access.mRange, MATERIAL_TEXTURE_RANGE) && + access.mKind == ImageAccessKind::SampledRead && access.mBefore == ImageState::ShaderRead && + access.mDuring == ImageState::ShaderRead && access.mAfter == ImageState::ShaderRead; + } + + bool canonicalColorAccess(const ImageAccess& access, ImageHandle handle) + { + return access.mImage == handle && sameRange(access.mRange, {}) && access.mKind == ImageAccessKind::ColorAttachmentWrite && + access.mBefore == ImageState::Undefined && access.mDuring == ImageState::ColorAttachment && + access.mAfter == ImageState::ShaderRead; + } + + bool canonicalDepthAccess(const ImageAccess& access, ImageHandle handle) + { + return access.mImage == handle && sameRange(access.mRange, {}) && access.mKind == ImageAccessKind::DepthAttachmentReadWrite && + access.mBefore == ImageState::DepthAttachment && access.mDuring == ImageState::DepthAttachment && + access.mAfter == ImageState::DepthAttachment; + } + + bool canonicalColorAttachment(const ColorAttachment& attachment, ImageHandle handle) + { + return attachment.mImage == handle && attachment.mSubresource == ImageSubresource{} && attachment.mLoad == LoadOp::Clear && + attachment.mStore == StoreOp::Store && zeroClear(attachment.mClear); + } + + bool canonicalSampledBinding(const SampledImageBinding& binding, std::uint32_t slot, ImageHandle image_handle, + SamplerHandle sampler_handle) + { + return binding.mBinding == slot && binding.mImage == image_handle && sameRange(binding.mRange, MATERIAL_TEXTURE_RANGE) && + binding.mSampler == sampler_handle; + } + + bool canonicalVertexBinding(const VertexBufferBinding& binding, std::uint32_t slot, BufferHandle buffer, std::uint64_t offset) + { + return binding.mBinding == slot && binding.mBuffer == buffer && binding.mOffset == offset; + } + + bool canonicalTarget(const ColorTargetState& target, PixelFormat format) + { + return target.mFormat == format && !target.mBlendEnabled && target.mWriteMask == 0xf; + } + + bool canonicalVertexLayout(const PipelineResource& pipeline) + { + if (pipeline.mVertexBindings.size() != 7 || pipeline.mVertexAttributes.size() != 7) + { + return false; + } + + constexpr std::array STRIDES{ 16, 16, 8, 4, 16, 8, 8 }; + constexpr std::array SEMANTICS{ VertexSemantic::Position, VertexSemantic::Normal, VertexSemantic::TexCoord0, + VertexSemantic::Color, VertexSemantic::Tangent, VertexSemantic::TexCoord1, + VertexSemantic::TexCoord2 }; + constexpr std::array FORMATS{ VertexFormat::Float3, VertexFormat::Float3, VertexFormat::Float2, + VertexFormat::UNorm8x4, VertexFormat::Float4, VertexFormat::Float2, + VertexFormat::Float2 }; + + for (std::size_t offset = 0; offset < STRIDES.size(); ++offset) + { + const VertexBindingLayout& binding = pipeline.mVertexBindings[offset]; + const VertexAttribute& attribute = pipeline.mVertexAttributes[offset]; + if (binding.mBinding != offset || binding.mStride != STRIDES[offset] || attribute.mSemantic != SEMANTICS[offset] || + attribute.mFormat != FORMATS[offset] || attribute.mBinding != offset || attribute.mOffset != 0) + { + return false; + } + } + return true; + } + +} // namespace + +bool validMaterialParameters(const MaterialParameters& parameters) noexcept +{ + return finite(parameters.mModelviewMatrix) && finite(parameters.mModelviewProjectionMatrix) && finite(parameters.mNormalMatrix) && + finite(parameters.mTextureMatrix0) && unitRange(parameters.mSpecularColor) && finite(parameters.mClipPlane) && + parameters.mEnvironmentIntensity >= 0.f && parameters.mEnvironmentIntensity <= 1.f && + (parameters.mEmissiveBrightness == 0.f || parameters.mEmissiveBrightness == 1.f) && + (parameters.mMirror == 0.f || parameters.mMirror == 1.f); +} + +std::optional buildMaterialFrame(const MaterialInputs& inputs) +{ + if (inputs.mFrame == 0 || !validHandles(inputs.mHandles) || !validMaterialParameters(inputs.mParameters)) + { + return std::nullopt; + } + + const MaterialHandles& handles = inputs.mHandles; + FrameSnapshot frame; + frame.mFrame = inputs.mFrame; + frame.mBuffers = { { handles.mVertexBuffer, MATERIAL_VERTEX_BUFFER_SIZE, ResourceLifetime::Persistent }, + { handles.mIndexBuffer, MATERIAL_INDEX_BUFFER_SIZE, ResourceLifetime::Persistent } }; + frame.mImages = { image(handles.mDiffuse, MATERIAL_TEXTURE_EXTENT, PixelFormat::RGBA8Unorm, MATERIAL_TEXTURE_MIP_LEVELS), + image(handles.mNormal, MATERIAL_TEXTURE_EXTENT, PixelFormat::RGBA8Unorm, MATERIAL_TEXTURE_MIP_LEVELS), + image(handles.mSpecular, MATERIAL_TEXTURE_EXTENT, PixelFormat::RGBA8Unorm, MATERIAL_TEXTURE_MIP_LEVELS), + image(handles.mGBuffer0, MATERIAL_FRAME_EXTENT, PixelFormat::RGBA8Unorm), + image(handles.mGBuffer1, MATERIAL_FRAME_EXTENT, PixelFormat::RGBA8Unorm), + image(handles.mGBuffer2, MATERIAL_FRAME_EXTENT, PixelFormat::RGBA16Unorm), + image(handles.mDepth, MATERIAL_FRAME_EXTENT, PixelFormat::Depth24Unorm) }; + frame.mSamplers.push_back(sampler(handles.mSampler)); + + PipelineResource pipeline; + pipeline.mHandle = handles.mPipeline; + pipeline.mProgram = { MATERIAL_PROGRAM, 0 }; + pipeline.mCullMode = CullMode::Back; + pipeline.mFrontFace = FrontFace::CounterClockwise; + pipeline.mDepthTestEnabled = true; + pipeline.mDepthWriteEnabled = true; + pipeline.mDepthCompare = CompareOp::LessOrEqual; + pipeline.mColorTargets = { { PixelFormat::RGBA8Unorm, false, 0xf }, + { PixelFormat::RGBA8Unorm, false, 0xf }, + { PixelFormat::RGBA16Unorm, false, 0xf } }; + pipeline.mDepthFormat = PixelFormat::Depth24Unorm; + pipeline.mVertexBindings = { { 0, 16 }, { 1, 16 }, { 2, 8 }, { 3, 4 }, { 4, 16 }, { 5, 8 }, { 6, 8 } }; + pipeline.mVertexAttributes = { + { VertexSemantic::Position, VertexFormat::Float3, 0, 0 }, { VertexSemantic::Normal, VertexFormat::Float3, 1, 0 }, + { VertexSemantic::TexCoord0, VertexFormat::Float2, 2, 0 }, { VertexSemantic::Color, VertexFormat::UNorm8x4, 3, 0 }, + { VertexSemantic::Tangent, VertexFormat::Float4, 4, 0 }, { VertexSemantic::TexCoord1, VertexFormat::Float2, 5, 0 }, + { VertexSemantic::TexCoord2, VertexFormat::Float2, 6, 0 } + }; + pipeline.mSampledImageBindings = { 0, 1, 2 }; + pipeline.mParameterBindings.push_back({ 0, sizeof(MaterialParameters) }); + frame.mPipelines.push_back(std::move(pipeline)); + + RenderPass pass; + pass.mId = handles.mPass; + pass.mLabel = MATERIAL_PASS; + pass.mExtent = MATERIAL_FRAME_EXTENT; + pass.mViewport = { 0.f, 0.f, static_cast(MATERIAL_FRAME_WIDTH), static_cast(MATERIAL_FRAME_HEIGHT), 0.f, 1.f }; + pass.mScissor = { 0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT }; + pass.mBufferAccesses = { { handles.mVertexBuffer, BufferAccessKind::VertexRead }, + { handles.mIndexBuffer, BufferAccessKind::IndexRead } }; + pass.mImageAccesses = { { handles.mDiffuse, MATERIAL_TEXTURE_RANGE, ImageAccessKind::SampledRead, ImageState::ShaderRead, + ImageState::ShaderRead, ImageState::ShaderRead }, + { handles.mNormal, MATERIAL_TEXTURE_RANGE, ImageAccessKind::SampledRead, ImageState::ShaderRead, + ImageState::ShaderRead, ImageState::ShaderRead }, + { handles.mSpecular, MATERIAL_TEXTURE_RANGE, ImageAccessKind::SampledRead, ImageState::ShaderRead, + ImageState::ShaderRead, ImageState::ShaderRead }, + { handles.mGBuffer0, + {}, + ImageAccessKind::ColorAttachmentWrite, + ImageState::Undefined, + ImageState::ColorAttachment, + ImageState::ShaderRead }, + { handles.mGBuffer1, + {}, + ImageAccessKind::ColorAttachmentWrite, + ImageState::Undefined, + ImageState::ColorAttachment, + ImageState::ShaderRead }, + { handles.mGBuffer2, + {}, + ImageAccessKind::ColorAttachmentWrite, + ImageState::Undefined, + ImageState::ColorAttachment, + ImageState::ShaderRead }, + { handles.mDepth, + {}, + ImageAccessKind::DepthAttachmentReadWrite, + ImageState::DepthAttachment, + ImageState::DepthAttachment, + ImageState::DepthAttachment } }; + pass.mColorAttachments = { { handles.mGBuffer0, {}, LoadOp::Clear, StoreOp::Store, {} }, + { handles.mGBuffer1, {}, LoadOp::Clear, StoreOp::Store, {} }, + { handles.mGBuffer2, {}, LoadOp::Clear, StoreOp::Store, {} } }; + pass.mDepthAttachment = DepthAttachment{ handles.mDepth, {}, LoadOp::Load, StoreOp::Store, 1.f }; + + DrawIndexed draw; + draw.mResources.mPipeline = handles.mPipeline; + draw.mResources.mVertexBuffers = { + { 0, handles.mVertexBuffer, MATERIAL_POSITION_OFFSET }, { 1, handles.mVertexBuffer, MATERIAL_NORMAL_OFFSET }, + { 2, handles.mVertexBuffer, MATERIAL_TEXCOORD0_OFFSET }, { 3, handles.mVertexBuffer, MATERIAL_COLOR_OFFSET }, + { 4, handles.mVertexBuffer, MATERIAL_TANGENT_OFFSET }, { 5, handles.mVertexBuffer, MATERIAL_TEXCOORD1_OFFSET }, + { 6, handles.mVertexBuffer, MATERIAL_TEXCOORD2_OFFSET } + }; + draw.mResources.mSampledImages = { { 0, handles.mDiffuse, MATERIAL_TEXTURE_RANGE, handles.mSampler }, + { 1, handles.mNormal, MATERIAL_TEXTURE_RANGE, handles.mSampler }, + { 2, handles.mSpecular, MATERIAL_TEXTURE_RANGE, handles.mSampler } }; + draw.mResources.mParameters.push_back({ 0, parameterBytes(inputs.mParameters) }); + draw.mIndexBuffer = { handles.mIndexBuffer, 0, IndexType::UInt16 }; + draw.mIndexCount = 6; + draw.mMinVertex = 0; + draw.mMaxVertex = 3; + pass.mDraws.emplace_back(std::move(draw)); + frame.mPasses.push_back(std::move(pass)); + + if (!static_cast(validate(frame))) + { + return std::nullopt; + } + return frame; +} + +std::optional decodeMaterialFrame(const FrameSnapshot& frame) +{ + if (!static_cast(validate(frame)) || !frame.mUploads.empty() || !frame.mReleases.empty() || frame.mBuffers.size() != 2 || + frame.mImages.size() != 7 || frame.mSamplers.size() != 1 || frame.mPipelines.size() != 1 || frame.mPasses.size() != 1) + { + return std::nullopt; + } + + const BufferResource& vertex_buffer = frame.mBuffers[0]; + const BufferResource& index_buffer = frame.mBuffers[1]; + const ImageResource& diffuse = frame.mImages[0]; + const ImageResource& normal = frame.mImages[1]; + const ImageResource& specular = frame.mImages[2]; + const ImageResource& gbuffer0 = frame.mImages[3]; + const ImageResource& gbuffer1 = frame.mImages[4]; + const ImageResource& gbuffer2 = frame.mImages[5]; + const ImageResource& depth = frame.mImages[6]; + const SamplerResource& material_sampler = frame.mSamplers.front(); + const PipelineResource& pipeline = frame.mPipelines.front(); + const RenderPass& pass = frame.mPasses.front(); + + MaterialInputs result; + result.mFrame = frame.mFrame; + result.mHandles = { vertex_buffer.mHandle, index_buffer.mHandle, diffuse.mHandle, normal.mHandle, + specular.mHandle, gbuffer0.mHandle, gbuffer1.mHandle, gbuffer2.mHandle, + depth.mHandle, material_sampler.mHandle, pipeline.mHandle, pass.mId }; + + if (!validHandles(result.mHandles) || vertex_buffer.mSize != MATERIAL_VERTEX_BUFFER_SIZE || + vertex_buffer.mLifetime != ResourceLifetime::Persistent || index_buffer.mSize != MATERIAL_INDEX_BUFFER_SIZE || + index_buffer.mLifetime != ResourceLifetime::Persistent || + !canonicalImage(diffuse, result.mHandles.mDiffuse, MATERIAL_TEXTURE_EXTENT, PixelFormat::RGBA8Unorm, MATERIAL_TEXTURE_MIP_LEVELS) || + !canonicalImage(normal, result.mHandles.mNormal, MATERIAL_TEXTURE_EXTENT, PixelFormat::RGBA8Unorm, MATERIAL_TEXTURE_MIP_LEVELS) || + !canonicalImage(specular, result.mHandles.mSpecular, MATERIAL_TEXTURE_EXTENT, PixelFormat::RGBA8Unorm, + MATERIAL_TEXTURE_MIP_LEVELS) || + !canonicalImage(gbuffer0, result.mHandles.mGBuffer0, MATERIAL_FRAME_EXTENT, PixelFormat::RGBA8Unorm) || + !canonicalImage(gbuffer1, result.mHandles.mGBuffer1, MATERIAL_FRAME_EXTENT, PixelFormat::RGBA8Unorm) || + !canonicalImage(gbuffer2, result.mHandles.mGBuffer2, MATERIAL_FRAME_EXTENT, PixelFormat::RGBA16Unorm) || + !canonicalImage(depth, result.mHandles.mDepth, MATERIAL_FRAME_EXTENT, PixelFormat::Depth24Unorm) || + !canonicalSampler(material_sampler, result.mHandles.mSampler)) + { + return std::nullopt; + } + + if (pipeline.mProgram.mName != MATERIAL_PROGRAM || pipeline.mProgram.mVariant != 0 || + pipeline.mTopology != PrimitiveTopology::TriangleList || pipeline.mCullMode != CullMode::Back || + pipeline.mFrontFace != FrontFace::CounterClockwise || !pipeline.mDepthTestEnabled || !pipeline.mDepthWriteEnabled || + pipeline.mDepthCompare != CompareOp::LessOrEqual || pipeline.mSamples != 1 || pipeline.mDepthFormat != PixelFormat::Depth24Unorm || + pipeline.mLifetime != ResourceLifetime::Persistent || pipeline.mColorTargets.size() != 3 || + !canonicalTarget(pipeline.mColorTargets[0], PixelFormat::RGBA8Unorm) || + !canonicalTarget(pipeline.mColorTargets[1], PixelFormat::RGBA8Unorm) || + !canonicalTarget(pipeline.mColorTargets[2], PixelFormat::RGBA16Unorm) || !canonicalVertexLayout(pipeline) || + pipeline.mSampledImageBindings != std::vector{ 0, 1, 2 } || pipeline.mParameterBindings.size() != 1 || + pipeline.mParameterBindings[0].mBinding != 0 || pipeline.mParameterBindings[0].mSize != sizeof(MaterialParameters)) + { + return std::nullopt; + } + + if (pass.mId != result.mHandles.mPass || pass.mLabel != MATERIAL_PASS || !sameExtent(pass.mExtent, MATERIAL_FRAME_EXTENT) || + pass.mViewport.mX != 0.f || pass.mViewport.mY != 0.f || pass.mViewport.mWidth != MATERIAL_FRAME_WIDTH || + pass.mViewport.mHeight != MATERIAL_FRAME_HEIGHT || pass.mViewport.mMinDepth != 0.f || pass.mViewport.mMaxDepth != 1.f || + pass.mScissor.mX != 0 || pass.mScissor.mY != 0 || pass.mScissor.mWidth != MATERIAL_FRAME_WIDTH || + pass.mScissor.mHeight != MATERIAL_FRAME_HEIGHT || !pass.mDependencies.empty() || pass.mBufferAccesses.size() != 2 || + pass.mBufferAccesses[0].mBuffer != result.mHandles.mVertexBuffer || pass.mBufferAccesses[0].mKind != BufferAccessKind::VertexRead || + pass.mBufferAccesses[1].mBuffer != result.mHandles.mIndexBuffer || pass.mBufferAccesses[1].mKind != BufferAccessKind::IndexRead || + pass.mImageAccesses.size() != 7 || !canonicalSampledAccess(pass.mImageAccesses[0], result.mHandles.mDiffuse) || + !canonicalSampledAccess(pass.mImageAccesses[1], result.mHandles.mNormal) || + !canonicalSampledAccess(pass.mImageAccesses[2], result.mHandles.mSpecular) || + !canonicalColorAccess(pass.mImageAccesses[3], result.mHandles.mGBuffer0) || + !canonicalColorAccess(pass.mImageAccesses[4], result.mHandles.mGBuffer1) || + !canonicalColorAccess(pass.mImageAccesses[5], result.mHandles.mGBuffer2) || + !canonicalDepthAccess(pass.mImageAccesses[6], result.mHandles.mDepth) || pass.mColorAttachments.size() != 3 || + !canonicalColorAttachment(pass.mColorAttachments[0], result.mHandles.mGBuffer0) || + !canonicalColorAttachment(pass.mColorAttachments[1], result.mHandles.mGBuffer1) || + !canonicalColorAttachment(pass.mColorAttachments[2], result.mHandles.mGBuffer2) || !pass.mDepthAttachment || + pass.mDepthAttachment->mImage != result.mHandles.mDepth || pass.mDepthAttachment->mSubresource != ImageSubresource{} || + pass.mDepthAttachment->mLoad != LoadOp::Load || pass.mDepthAttachment->mStore != StoreOp::Store || + pass.mDepthAttachment->mClearDepth != 1.f || pass.mDraws.size() != 1 || !std::holds_alternative(pass.mDraws.front())) + { + return std::nullopt; + } + + const DrawIndexed& draw = std::get(pass.mDraws.front()); + if (draw.mResources.mPipeline != result.mHandles.mPipeline || draw.mResources.mVertexBuffers.size() != 7 || + !canonicalVertexBinding(draw.mResources.mVertexBuffers[0], 0, result.mHandles.mVertexBuffer, MATERIAL_POSITION_OFFSET) || + !canonicalVertexBinding(draw.mResources.mVertexBuffers[1], 1, result.mHandles.mVertexBuffer, MATERIAL_NORMAL_OFFSET) || + !canonicalVertexBinding(draw.mResources.mVertexBuffers[2], 2, result.mHandles.mVertexBuffer, MATERIAL_TEXCOORD0_OFFSET) || + !canonicalVertexBinding(draw.mResources.mVertexBuffers[3], 3, result.mHandles.mVertexBuffer, MATERIAL_COLOR_OFFSET) || + !canonicalVertexBinding(draw.mResources.mVertexBuffers[4], 4, result.mHandles.mVertexBuffer, MATERIAL_TANGENT_OFFSET) || + !canonicalVertexBinding(draw.mResources.mVertexBuffers[5], 5, result.mHandles.mVertexBuffer, MATERIAL_TEXCOORD1_OFFSET) || + !canonicalVertexBinding(draw.mResources.mVertexBuffers[6], 6, result.mHandles.mVertexBuffer, MATERIAL_TEXCOORD2_OFFSET) || + draw.mResources.mSampledImages.size() != 3 || + !canonicalSampledBinding(draw.mResources.mSampledImages[0], 0, result.mHandles.mDiffuse, result.mHandles.mSampler) || + !canonicalSampledBinding(draw.mResources.mSampledImages[1], 1, result.mHandles.mNormal, result.mHandles.mSampler) || + !canonicalSampledBinding(draw.mResources.mSampledImages[2], 2, result.mHandles.mSpecular, result.mHandles.mSampler) || + draw.mResources.mParameters.size() != 1 || draw.mResources.mParameters[0].mBinding != 0 || + draw.mResources.mParameters[0].mBytes.mSize != sizeof(MaterialParameters) || + draw.mIndexBuffer.mBuffer != result.mHandles.mIndexBuffer || draw.mIndexBuffer.mOffset != 0 || + draw.mIndexBuffer.mType != IndexType::UInt16 || draw.mFirstIndex != 0 || draw.mIndexCount != 6 || draw.mBaseVertex != 0 || + draw.mMinVertex != 0 || draw.mMaxVertex != 3 || draw.mFirstInstance != 0 || draw.mInstanceCount != 1) + { + return std::nullopt; + } + + const ByteRange& bytes = draw.mResources.mParameters[0].mBytes; + if (bytes.mOffset != 0 || bytes.mStorage->size() != sizeof(MaterialParameters)) + { + return std::nullopt; + } + std::memcpy(&result.mParameters, bytes.mStorage->data() + bytes.mOffset, sizeof(result.mParameters)); + if (!validMaterialParameters(result.mParameters)) + { + return std::nullopt; + } + return result; +} + +} // namespace LLRenderContract diff --git a/indra/llrender/llmaterialcontract.h b/indra/llrender/llmaterialcontract.h new file mode 100644 index 00000000000..af46dbc98ce --- /dev/null +++ b/indra/llrender/llmaterialcontract.h @@ -0,0 +1,112 @@ +/** + * @file llmaterialcontract.h + * @brief Backend-neutral description of one indexed legacy material draw. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLMATERIALCONTRACT_H +#define LL_LLMATERIALCONTRACT_H + +#include "llrendercontract.h" + +#include +#include +#include +#include + +namespace LLRenderContract +{ + +inline constexpr std::uint32_t MATERIAL_FRAME_WIDTH = 8; +inline constexpr std::uint32_t MATERIAL_FRAME_HEIGHT = 8; +inline constexpr std::uint32_t MATERIAL_TEXTURE_WIDTH = 4; +inline constexpr std::uint32_t MATERIAL_TEXTURE_HEIGHT = 4; +inline constexpr std::uint32_t MATERIAL_TEXTURE_MIP_LEVELS = 3; +inline constexpr std::uint64_t MATERIAL_VERTEX_BUFFER_SIZE = 304; +inline constexpr std::uint64_t MATERIAL_INDEX_BUFFER_SIZE = 12; +inline constexpr std::array MATERIAL_INDICES{ 0, 1, 2, 0, 2, 3 }; + +inline constexpr std::uint64_t MATERIAL_POSITION_OFFSET = 0; +inline constexpr std::uint64_t MATERIAL_NORMAL_OFFSET = 64; +inline constexpr std::uint64_t MATERIAL_TEXCOORD0_OFFSET = 128; +inline constexpr std::uint64_t MATERIAL_TEXCOORD1_OFFSET = 160; +inline constexpr std::uint64_t MATERIAL_TEXCOORD2_OFFSET = 192; +inline constexpr std::uint64_t MATERIAL_COLOR_OFFSET = 224; +inline constexpr std::uint64_t MATERIAL_TANGENT_OFFSET = 240; + +// This is the complete state consumed by the selected non-rigged material +// shader. The old 160-byte validation blob was only a placeholder and omitted +// shader-visible transforms and clipping state. +struct MaterialParameters +{ + std::array mModelviewMatrix{}; + std::array mModelviewProjectionMatrix{}; + std::array mNormalMatrix{}; + std::array mTextureMatrix0{}; + std::array mSpecularColor{}; + std::array mClipPlane{}; + float mEnvironmentIntensity = 0.f; + float mEmissiveBrightness = 0.f; + float mMirror = 0.f; + + friend constexpr bool operator==(const MaterialParameters&, const MaterialParameters&) = default; +}; + +static_assert(sizeof(MaterialParameters) == 272, "material parameters must remain a 68-word packet"); +static_assert(offsetof(MaterialParameters, mModelviewMatrix) == 0); +static_assert(offsetof(MaterialParameters, mModelviewProjectionMatrix) == 64); +static_assert(offsetof(MaterialParameters, mNormalMatrix) == 128); +static_assert(offsetof(MaterialParameters, mTextureMatrix0) == 164); +static_assert(offsetof(MaterialParameters, mSpecularColor) == 228); +static_assert(offsetof(MaterialParameters, mClipPlane) == 244); +static_assert(offsetof(MaterialParameters, mEnvironmentIntensity) == 260); +static_assert(offsetof(MaterialParameters, mEmissiveBrightness) == 264); +static_assert(offsetof(MaterialParameters, mMirror) == 268); + +struct MaterialHandles +{ + BufferHandle mVertexBuffer{ 1, 1 }; + BufferHandle mIndexBuffer{ 2, 1 }; + ImageHandle mDiffuse{ 1, 1 }; + ImageHandle mNormal{ 2, 1 }; + ImageHandle mSpecular{ 3, 1 }; + ImageHandle mGBuffer0{ 4, 1 }; + ImageHandle mGBuffer1{ 5, 1 }; + ImageHandle mGBuffer2{ 6, 1 }; + ImageHandle mDepth{ 7, 1 }; + SamplerHandle mSampler{ 1, 1 }; + PipelineHandle mPipeline{ 1, 1 }; + PassId mPass{ 1 }; + + friend constexpr bool operator==(const MaterialHandles&, const MaterialHandles&) = default; +}; + +struct MaterialInputs +{ + std::uint64_t mFrame = 0; + MaterialHandles mHandles; + MaterialParameters mParameters; + + friend constexpr bool operator==(const MaterialInputs&, const MaterialInputs&) = default; +}; + +bool validMaterialParameters(const MaterialParameters& parameters) noexcept; + +// Returns no packet when policy supplied an invalid handle or parameter value. +std::optional buildMaterialFrame(const MaterialInputs& inputs); + +// Accepts only the fixed Stage 12 material packet and returns owned values. +std::optional decodeMaterialFrame(const FrameSnapshot& frame); + +} // namespace LLRenderContract + +#endif diff --git a/indra/llrender/llmaterialdiagnostic.cpp b/indra/llrender/llmaterialdiagnostic.cpp new file mode 100644 index 00000000000..5e40ba33b91 --- /dev/null +++ b/indra/llrender/llmaterialdiagnostic.cpp @@ -0,0 +1,745 @@ +/** + * @file llmaterialdiagnostic.cpp + * @brief Deterministic fixture and artifact for the indexed material draw. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llmaterialdiagnostic.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + + static_assert(sizeof(float) == sizeof(std::uint32_t), "material artifacts require 32-bit floats"); + static_assert(std::numeric_limits::is_iec559, "material artifacts require IEEE 754 floats"); + + constexpr std::array ARTIFACT_MAGIC{ 'L', 'L', 'M', 'A', 'T', 'E', 'R', 'L' }; + constexpr std::uint32_t BOTTOM_LEFT_WIRE_VALUE = 1; + constexpr std::uint32_t GBUFFER0_PLANE = 1; + constexpr std::uint32_t GBUFFER1_PLANE = 2; + constexpr std::uint32_t GBUFFER2_PLANE = 3; + constexpr std::uint32_t DEPTH_PLANE = 4; + constexpr std::uint32_t RGBA8_WIRE_VALUE = 1; + constexpr std::uint32_t RGBA16_WIRE_VALUE = 2; + constexpr std::uint32_t DEPTH24_WIRE_VALUE = 3; + constexpr std::size_t ARTIFACT_HEADER_SIZE = 44; + constexpr std::size_t ARTIFACT_PLANE_METADATA_SIZE = 16; + constexpr std::size_t ARTIFACT_PLANE_COUNT = 4; + constexpr std::size_t ARTIFACT_COMPONENT_COUNT = + MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT * 3 + MATERIAL_DIAGNOSTIC_DEPTH_COMPONENT_COUNT; + constexpr std::size_t ARTIFACT_SIZE = + ARTIFACT_HEADER_SIZE + ARTIFACT_PLANE_COUNT * ARTIFACT_PLANE_METADATA_SIZE + ARTIFACT_COMPONENT_COUNT * sizeof(float); + static_assert(ARTIFACT_SIZE == MATERIAL_ARTIFACT_BYTE_SIZE); + + struct PlaneSpec + { + std::uint32_t mId = 0; + std::uint32_t mFormat = 0; + std::uint32_t mChannels = 0; + std::size_t mComponents = 0; + std::uint32_t mMaximumCode = 0; + float mTolerance = 0.f; + const char* mName = nullptr; + }; + + constexpr std::array PLANES{ + PlaneSpec{ GBUFFER0_PLANE, RGBA8_WIRE_VALUE, 4, MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT, 0xffU, MATERIAL_RGBA8_TOLERANCE, + "G-buffer 0" }, + PlaneSpec{ GBUFFER1_PLANE, RGBA8_WIRE_VALUE, 4, MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT, 0xffU, MATERIAL_RGBA8_TOLERANCE, + "G-buffer 1" }, + PlaneSpec{ GBUFFER2_PLANE, RGBA16_WIRE_VALUE, 4, MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT, 0xffffU, MATERIAL_RGBA16_TOLERANCE, + "G-buffer 2" }, + PlaneSpec{ DEPTH_PLANE, DEPTH24_WIRE_VALUE, 1, MATERIAL_DIAGNOSTIC_DEPTH_COMPONENT_COUNT, 0xffffffU, MATERIAL_DEPTH24_TOLERANCE, + "depth" } + }; + + std::uint32_t floatBits(float value) noexcept + { + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; + } + + float bitsToFloat(std::uint32_t bits) noexcept + { + float value = 0.f; + std::memcpy(&value, &bits, sizeof(value)); + return value; + } + + void clearError(std::string* error) + { + if (error) + { + error->clear(); + } + } + + bool fail(std::string* error, std::string message) + { + if (error) + { + *error = std::move(message); + } + return false; + } + + const std::vector& plane(const MaterialArtifact& artifact, std::size_t index) + { + switch (index) + { + case 0: + return artifact.mGBuffer0RGBA8; + case 1: + return artifact.mGBuffer1RGBA8; + case 2: + return artifact.mGBuffer2RGBA16; + default: + return artifact.mDepth24; + } + } + + std::vector& plane(MaterialArtifact& artifact, std::size_t index) + { + return const_cast&>(plane(static_cast(artifact), index)); + } + + float normalizedCode(std::uint32_t code, std::uint32_t maximum) noexcept + { + if (code > maximum) + { + return std::numeric_limits::quiet_NaN(); + } + return static_cast(code) / static_cast(maximum); + } + + bool canonicalValue(float value, std::uint32_t maximum_code) + { + if (!std::isfinite(value) || value < 0.f || value > 1.f) + { + return false; + } + const auto code = static_cast(std::llround(static_cast(value) * maximum_code)); + return code <= maximum_code && value == normalizedCode(code, maximum_code); + } + + void appendU32(std::vector& bytes, std::uint32_t value) + { + bytes.push_back(static_cast(value >> 24)); + bytes.push_back(static_cast(value >> 16)); + bytes.push_back(static_cast(value >> 8)); + bytes.push_back(static_cast(value)); + } + + void appendU64(std::vector& bytes, std::uint64_t value) + { + appendU32(bytes, static_cast(value >> 32)); + appendU32(bytes, static_cast(value)); + } + + void appendFloat(std::vector& bytes, float value) + { + appendU32(bytes, floatBits(value == 0.f ? 0.f : value)); + } + + class ArtifactReader + { + public: + explicit ArtifactReader(const std::vector& bytes) : mBytes(bytes) {} + + bool readU32(std::uint32_t& value) + { + if (mOffset > mBytes.size() || mBytes.size() - mOffset < sizeof(value)) + { + return false; + } + value = (static_cast(mBytes[mOffset]) << 24) | (static_cast(mBytes[mOffset + 1]) << 16) | + (static_cast(mBytes[mOffset + 2]) << 8) | static_cast(mBytes[mOffset + 3]); + mOffset += sizeof(value); + return true; + } + + bool readU64(std::uint64_t& value) + { + std::uint32_t high = 0; + std::uint32_t low = 0; + if (!readU32(high) || !readU32(low)) + { + return false; + } + value = (static_cast(high) << 32) | low; + return true; + } + + bool readBytes(std::uint8_t* destination, std::size_t size) + { + if (mOffset > mBytes.size() || mBytes.size() - mOffset < size) + { + return false; + } + std::copy_n(mBytes.data() + mOffset, size, destination); + mOffset += size; + return true; + } + + std::size_t offset() const noexcept { return mOffset; } + + private: + const std::vector& mBytes; + std::size_t mOffset = 0; + }; + + bool expectedU32(ArtifactReader& reader, std::uint32_t expected, const std::string& field, std::string* error) + { + std::uint32_t actual = 0; + if (!reader.readU32(actual)) + { + return fail(error, "artifact ends before " + field); + } + if (actual != expected) + { + std::ostringstream message; + message << field << " is " << actual << ", expected " << expected; + return fail(error, message.str()); + } + return true; + } + + bool expectedU64(ArtifactReader& reader, std::uint64_t expected, const std::string& field, std::string* error) + { + std::uint64_t actual = 0; + if (!reader.readU64(actual)) + { + return fail(error, "artifact ends before " + field); + } + if (actual != expected) + { + std::ostringstream message; + message << field << " is " << actual << ", expected " << expected; + return fail(error, message.str()); + } + return true; + } + + void hashByte(std::uint64_t& hash, std::uint8_t value) + { + constexpr std::uint64_t FNV_PRIME = 1099511628211ULL; + hash ^= value; + hash *= FNV_PRIME; + } + + void hashU16(std::uint64_t& hash, std::uint16_t value) + { + hashByte(hash, static_cast(value >> 8)); + hashByte(hash, static_cast(value)); + } + + void hashU32(std::uint64_t& hash, std::uint32_t value) + { + hashByte(hash, static_cast(value >> 24)); + hashByte(hash, static_cast(value >> 16)); + hashByte(hash, static_cast(value >> 8)); + hashByte(hash, static_cast(value)); + } + + template + void hashFloats(std::uint64_t& hash, const std::array& values) + { + for (float value : values) + { + hashU32(hash, floatBits(value)); + } + } + + void hashParameters(std::uint64_t& hash, const MaterialParameters& parameters) + { + hashFloats(hash, parameters.mModelviewMatrix); + hashFloats(hash, parameters.mModelviewProjectionMatrix); + hashFloats(hash, parameters.mNormalMatrix); + hashFloats(hash, parameters.mTextureMatrix0); + hashFloats(hash, parameters.mSpecularColor); + hashFloats(hash, parameters.mClipPlane); + hashU32(hash, floatBits(parameters.mEnvironmentIntensity)); + hashU32(hash, floatBits(parameters.mEmissiveBrightness)); + hashU32(hash, floatBits(parameters.mMirror)); + } + + template + void copyFloats(std::array& destination, std::size_t offset, + const std::array& source) + { + std::memcpy(destination.data() + offset, source.data(), sizeof(source)); + } + + template + void identity(std::array& matrix, std::size_t dimension) + { + for (std::size_t diagonal = 0; diagonal < dimension; ++diagonal) + { + matrix[diagonal * dimension + diagonal] = 1.f; + } + } + + std::filesystem::path temporaryArtifactPath(const std::filesystem::path& destination) + { + static std::atomic serial{ 0 }; + const auto now = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::uint64_t sequence = serial.fetch_add(1, std::memory_order_relaxed); + const std::uint64_t token = + static_cast(now) ^ (sequence + 0x9e3779b97f4a7c15ULL + (sequence << 6) + (sequence >> 2)); + std::ostringstream suffix; + suffix << ".tmp." << std::hex << token; + std::filesystem::path temporary = destination; + temporary += suffix.str(); + return temporary; + } + + std::FILE* openExclusive(const std::filesystem::path& path, int& open_error) + { +#if defined(_WIN32) + std::FILE* file = nullptr; + open_error = _wfopen_s(&file, path.c_str(), L"wbx"); + return file; +#else + errno = 0; + std::FILE* file = std::fopen(path.c_str(), "wbx"); + open_error = errno; + return file; +#endif + } + +} // namespace + +float materialUnorm8(std::uint8_t code) noexcept +{ + return normalizedCode(code, 0xffU); +} + +float materialUnorm16(std::uint16_t code) noexcept +{ + return normalizedCode(code, 0xffffU); +} + +float materialDepth24(std::uint32_t code) noexcept +{ + return normalizedCode(code, 0xffffffU); +} + +MaterialFixture makeMaterialFixture() +{ + MaterialFixture fixture; + + constexpr std::array POSITIONS{ -0.82f, -0.74f, 0.18f, 1.f, 0.78f, -0.61f, 0.42f, 1.f, + 0.63f, 0.81f, -0.12f, 1.f, -0.71f, 0.57f, 0.31f, 1.f }; + constexpr std::array NORMALS{ 0.10f, 0.20f, 0.97f, 0.f, -0.18f, 0.12f, 0.98f, 0.f, + 0.24f, -0.08f, 0.96f, 0.f, -0.12f, -0.22f, 0.97f, 0.f }; + constexpr std::array TEXCOORD0{ 0.15f, 0.05f, 2.85f, 0.25f, 2.45f, 2.95f, -0.35f, 2.40f }; + constexpr std::array TEXCOORD1{ 0.35f, 0.20f, 3.75f, 0.55f, 2.90f, 3.60f, -0.60f, 2.75f }; + constexpr std::array TEXCOORD2{ 0.05f, 0.45f, 2.20f, -0.15f, 3.20f, 2.30f, -0.25f, 3.10f }; + constexpr std::array COLORS{ 241, 109, 53, 229, 67, 223, 137, 197, 151, 79, 239, 173, 211, 187, 41, 251 }; + constexpr std::array TANGENTS{ 0.98f, 0.05f, 0.18f, 1.f, 0.94f, -0.21f, 0.26f, -1.f, + 0.91f, 0.31f, -0.19f, 1.f, 0.96f, -0.11f, -0.24f, -1.f }; + + copyFloats(fixture.mVertexBytes, MATERIAL_POSITION_OFFSET, POSITIONS); + copyFloats(fixture.mVertexBytes, MATERIAL_NORMAL_OFFSET, NORMALS); + copyFloats(fixture.mVertexBytes, MATERIAL_TEXCOORD0_OFFSET, TEXCOORD0); + copyFloats(fixture.mVertexBytes, MATERIAL_TEXCOORD1_OFFSET, TEXCOORD1); + copyFloats(fixture.mVertexBytes, MATERIAL_TEXCOORD2_OFFSET, TEXCOORD2); + std::copy(COLORS.begin(), COLORS.end(), fixture.mVertexBytes.begin() + MATERIAL_COLOR_OFFSET); + copyFloats(fixture.mVertexBytes, MATERIAL_TANGENT_OFFSET, TANGENTS); + fixture.mIndices = MATERIAL_INDICES; + + for (std::size_t texture = 0; texture < fixture.mTextureRGBA8.size(); ++texture) + { + for (std::size_t mip = 0; mip < MATERIAL_TEXTURE_MIP_LEVELS; ++mip) + { + const std::size_t first = MATERIAL_TEXTURE_MIP_BYTE_OFFSETS[mip]; + const std::size_t size = MATERIAL_TEXTURE_MIP_BYTE_SIZES[mip]; + for (std::size_t component = 0; component < size; ++component) + { + const std::size_t texel = component / MATERIAL_DIAGNOSTIC_CHANNELS; + const std::size_t channel = component % MATERIAL_DIAGNOSTIC_CHANNELS; + fixture.mTextureRGBA8[texture][first + component] = + static_cast(1 + (texture * 71 + mip * 43 + texel * 29 + channel * 53 + 17) % 255); + } + } + } + + identity(fixture.mParameters.mModelviewMatrix, 4); + identity(fixture.mParameters.mModelviewProjectionMatrix, 4); + identity(fixture.mParameters.mNormalMatrix, 3); + identity(fixture.mParameters.mTextureMatrix0, 4); + fixture.mParameters.mSpecularColor = { 0.31f, 0.57f, 0.83f, 0.68f }; + // Mirror clipping at view-space x=0 keeps the right half and discards the + // left half of the asymmetric quad. + fixture.mParameters.mClipPlane = { 1.f, 0.f, 0.f, 0.f }; + fixture.mParameters.mEnvironmentIntensity = 0.625f; + fixture.mParameters.mEmissiveBrightness = 1.f; + fixture.mParameters.mMirror = 1.f; + + for (std::size_t pixel = 0; pixel < MATERIAL_DIAGNOSTIC_PIXEL_COUNT; ++pixel) + { + const std::size_t x = pixel % MATERIAL_FRAME_WIDTH; + const std::size_t y = pixel / MATERIAL_FRAME_WIDTH; + const std::uint32_t base = (x + y) % 2 == 0 ? 0xd00000U : 0x280000U; + fixture.mDepth24[pixel] = static_cast(base + (pixel * 0x1f123U + x * 0x207U) % 0x18000U); + for (std::size_t channel = 0; channel < MATERIAL_DIAGNOSTIC_CHANNELS; ++channel) + { + const std::size_t component = pixel * MATERIAL_DIAGNOSTIC_CHANNELS + channel; + fixture.mGBuffer0SentinelRGBA8[component] = static_cast(1 + (pixel * 19 + channel * 47 + 13) % 255); + fixture.mGBuffer1SentinelRGBA8[component] = static_cast(1 + (pixel * 31 + channel * 37 + 101) % 255); + fixture.mGBuffer2SentinelRGBA16[component] = static_cast(1 + (pixel * 1237 + channel * 7919 + 4001) % 65535); + } + } + return fixture; +} + +std::uint64_t materialFixtureFingerprint() +{ + constexpr std::uint64_t FNV_OFFSET_BASIS = 14695981039346656037ULL; + const MaterialFixture fixture = makeMaterialFixture(); + std::uint64_t hash = FNV_OFFSET_BASIS; + hashU32(hash, MATERIAL_DIAGNOSTIC_FIXTURE_VERSION); + hashU32(hash, fixture.mExtent.mWidth); + hashU32(hash, fixture.mExtent.mHeight); + hashU32(hash, BOTTOM_LEFT_WIRE_VALUE); + for (std::uint8_t value : fixture.mVertexBytes) + hashByte(hash, value); + for (std::uint16_t value : fixture.mIndices) + hashU16(hash, value); + for (const auto& texture : fixture.mTextureRGBA8) + { + for (std::uint8_t value : texture) + hashByte(hash, value); + } + hashParameters(hash, fixture.mParameters); + for (std::uint32_t value : fixture.mDepth24) + hashU32(hash, value); + for (std::uint8_t value : fixture.mGBuffer0SentinelRGBA8) + hashByte(hash, value); + for (std::uint8_t value : fixture.mGBuffer1SentinelRGBA8) + hashByte(hash, value); + for (std::uint16_t value : fixture.mGBuffer2SentinelRGBA16) + hashU16(hash, value); + return hash; +} + +MaterialCase makeMaterialCase() +{ + MaterialCase result; + const MaterialFixture fixture = makeMaterialFixture(); + result.mInputs.mFrame = 1; + result.mInputs.mParameters = fixture.mParameters; + auto frame = buildMaterialFrame(result.mInputs); + if (!frame) + { + std::terminate(); + } + result.mFrame = std::move(*frame); + return result; +} + +MaterialArtifact makeMaterialArtifact() +{ + return {}; +} + +bool validateMaterialArtifact(const MaterialArtifact& artifact, std::string* error) +{ + clearError(error); + for (std::size_t plane_index = 0; plane_index < PLANES.size(); ++plane_index) + { + const PlaneSpec& spec = PLANES[plane_index]; + const std::vector& values = plane(artifact, plane_index); + if (values.size() != spec.mComponents) + { + std::ostringstream message; + message << spec.mName << " has " << values.size() << " components, expected " << spec.mComponents; + return fail(error, message.str()); + } + for (std::size_t component = 0; component < values.size(); ++component) + { + if (!canonicalValue(values[component], spec.mMaximumCode)) + { + std::ostringstream message; + message << spec.mName << " component " << component << " is not a finite value representable by its storage format"; + return fail(error, message.str()); + } + } + } + return true; +} + +bool encodeMaterialArtifact(const MaterialArtifact& artifact, std::vector& encoded, std::string* error) +{ + clearError(error); + if (!validateMaterialArtifact(artifact, error)) + { + return false; + } + + std::vector result; + result.reserve(ARTIFACT_SIZE); + result.insert(result.end(), ARTIFACT_MAGIC.begin(), ARTIFACT_MAGIC.end()); + appendU32(result, MATERIAL_ARTIFACT_SCHEMA_VERSION); + appendU32(result, MATERIAL_DIAGNOSTIC_FIXTURE_VERSION); + appendU64(result, materialFixtureFingerprint()); + appendU32(result, MATERIAL_FRAME_WIDTH); + appendU32(result, MATERIAL_FRAME_HEIGHT); + appendU32(result, MATERIAL_DIAGNOSTIC_CHANNELS); + appendU32(result, BOTTOM_LEFT_WIRE_VALUE); + appendU32(result, static_cast(PLANES.size())); + + for (std::size_t plane_index = 0; plane_index < PLANES.size(); ++plane_index) + { + const PlaneSpec& spec = PLANES[plane_index]; + const std::vector& values = plane(artifact, plane_index); + appendU32(result, spec.mId); + appendU32(result, spec.mFormat); + appendU32(result, spec.mChannels); + appendU32(result, static_cast(spec.mComponents)); + for (float value : values) + { + appendFloat(result, value); + } + } + + if (result.size() != ARTIFACT_SIZE) + { + return fail(error, "artifact encoder produced an unexpected byte count"); + } + encoded = std::move(result); + return true; +} + +bool decodeMaterialArtifact(const std::vector& encoded, MaterialArtifact& artifact, std::string* error) +{ + clearError(error); + if (encoded.size() != ARTIFACT_SIZE) + { + std::ostringstream message; + message << "artifact has " << encoded.size() << " bytes, expected " << ARTIFACT_SIZE; + return fail(error, message.str()); + } + + ArtifactReader reader(encoded); + std::array magic{}; + if (!reader.readBytes(magic.data(), magic.size()) || magic != ARTIFACT_MAGIC) + { + return fail(error, "artifact magic is invalid"); + } + if (!expectedU32(reader, MATERIAL_ARTIFACT_SCHEMA_VERSION, "schema version", error) || + !expectedU32(reader, MATERIAL_DIAGNOSTIC_FIXTURE_VERSION, "fixture version", error) || + !expectedU64(reader, materialFixtureFingerprint(), "fixture fingerprint", error) || + !expectedU32(reader, MATERIAL_FRAME_WIDTH, "width", error) || !expectedU32(reader, MATERIAL_FRAME_HEIGHT, "height", error) || + !expectedU32(reader, MATERIAL_DIAGNOSTIC_CHANNELS, "color channel count", error) || + !expectedU32(reader, BOTTOM_LEFT_WIRE_VALUE, "row origin", error) || + !expectedU32(reader, static_cast(PLANES.size()), "plane count", error)) + { + return false; + } + + MaterialArtifact result = makeMaterialArtifact(); + for (std::size_t plane_index = 0; plane_index < PLANES.size(); ++plane_index) + { + const PlaneSpec& spec = PLANES[plane_index]; + const std::string prefix = std::string(spec.mName) + ' '; + if (!expectedU32(reader, spec.mId, prefix + "plane id", error) || !expectedU32(reader, spec.mFormat, prefix + "format", error) || + !expectedU32(reader, spec.mChannels, prefix + "channel count", error) || + !expectedU32(reader, static_cast(spec.mComponents), prefix + "component count", error)) + { + return false; + } + + std::vector& values = plane(result, plane_index); + values.reserve(spec.mComponents); + for (std::size_t component = 0; component < spec.mComponents; ++component) + { + std::uint32_t bits = 0; + if (!reader.readU32(bits)) + { + return fail(error, prefix + "payload is truncated"); + } + if (bits == 0x80000000U) + { + return fail(error, prefix + "payload contains non-canonical negative zero"); + } + const float value = bitsToFloat(bits); + if (!canonicalValue(value, spec.mMaximumCode)) + { + std::ostringstream message; + message << prefix << "component " << component << " is not a finite value representable by its storage format"; + return fail(error, message.str()); + } + values.push_back(value); + } + } + + if (reader.offset() != encoded.size()) + { + return fail(error, "artifact has trailing data"); + } + artifact = std::move(result); + return true; +} + +bool writeMaterialArtifact(const std::filesystem::path& destination, const MaterialArtifact& artifact, std::string* error) +{ + clearError(error); + if (destination.empty() || destination.filename().empty()) + { + return fail(error, "artifact destination must name a file"); + } + + std::vector encoded; + if (!encodeMaterialArtifact(artifact, encoded, error)) + { + return false; + } + + std::filesystem::path temporary; + std::FILE* output = nullptr; + int open_error = 0; + for (std::size_t attempt = 0; attempt < 64 && !output; ++attempt) + { + temporary = temporaryArtifactPath(destination); + output = openExclusive(temporary, open_error); + if (!output && open_error != EEXIST) + { + return fail(error, "cannot create artifact temporary file: " + std::error_code(open_error, std::generic_category()).message()); + } + } + if (!output) + { + return fail(error, "cannot reserve a unique artifact temporary file"); + } + + const bool wrote = std::fwrite(encoded.data(), 1, encoded.size(), output) == encoded.size(); + const bool flushed = wrote && std::fflush(output) == 0; + const bool closed = std::fclose(output) == 0; + if (!wrote || !flushed || !closed) + { + std::error_code cleanup_error; + std::filesystem::remove(temporary, cleanup_error); + return fail(error, "cannot write artifact temporary file: " + temporary.string()); + } + + std::error_code file_error; + std::filesystem::create_hard_link(temporary, destination, file_error); + if (file_error) + { + std::error_code cleanup_error; + std::filesystem::remove(temporary, cleanup_error); + return fail(error, "cannot publish artifact: " + file_error.message()); + } + std::filesystem::remove(temporary, file_error); + if (file_error) + { + return fail(error, "artifact published but its temporary link could not be removed: " + file_error.message()); + } + return true; +} + +bool readMaterialArtifact(const std::filesystem::path& source, MaterialArtifact& artifact, std::string* error) +{ + clearError(error); + std::error_code file_error; + const std::uintmax_t size = std::filesystem::file_size(source, file_error); + if (file_error) + { + return fail(error, "cannot inspect artifact file: " + file_error.message()); + } + if (size != ARTIFACT_SIZE) + { + std::ostringstream message; + message << "artifact file has " << size << " bytes, expected " << ARTIFACT_SIZE; + return fail(error, message.str()); + } + + std::ifstream input(source, std::ios::binary | std::ios::in); + if (!input) + { + return fail(error, "cannot open artifact file: " + source.string()); + } + std::vector encoded(static_cast(size)); + input.read(reinterpret_cast(encoded.data()), static_cast(encoded.size())); + if (!input || input.peek() != std::ifstream::traits_type::eof()) + { + return fail(error, "cannot read complete artifact file: " + source.string()); + } + return decodeMaterialArtifact(encoded, artifact, error); +} + +MaterialComparisonStats compareMaterialArtifacts(const MaterialArtifact& reference, const MaterialArtifact& candidate) +{ + MaterialComparisonStats stats; + std::string validation_error; + if (!validateMaterialArtifact(reference, &validation_error)) + { + stats.mError = "reference " + validation_error; + return stats; + } + if (!validateMaterialArtifact(candidate, &validation_error)) + { + stats.mError = "candidate " + validation_error; + return stats; + } + + stats.mComparable = true; + for (std::size_t plane_index = 0; plane_index < PLANES.size(); ++plane_index) + { + const PlaneSpec& spec = PLANES[plane_index]; + const std::vector& reference_values = plane(reference, plane_index); + const std::vector& candidate_values = plane(candidate, plane_index); + for (std::size_t component = 0; component < reference_values.size(); ++component) + { + const float reference_value = reference_values[component]; + const float candidate_value = candidate_values[component]; + const double delta = std::fabs(static_cast(reference_value) - candidate_value); + stats.mMaximumAbsoluteError = std::max(stats.mMaximumAbsoluteError, delta); + ++stats.mComparedComponents; + if (delta <= spec.mTolerance) + { + continue; + } + + ++stats.mMismatchCount; + if (stats.mFirstMismatchPlane == 0) + { + stats.mFirstMismatchPlane = spec.mId; + stats.mFirstMismatchPixel = component / spec.mChannels; + stats.mFirstMismatchChannel = static_cast(component % spec.mChannels); + stats.mFirstReference = reference_value; + stats.mFirstCandidate = candidate_value; + stats.mFirstTolerance = spec.mTolerance; + } + } + } + stats.mMatch = stats.mMismatchCount == 0; + return stats; +} + +} // namespace LLRenderContract diff --git a/indra/llrender/llmaterialdiagnostic.h b/indra/llrender/llmaterialdiagnostic.h new file mode 100644 index 00000000000..9a7bd188e76 --- /dev/null +++ b/indra/llrender/llmaterialdiagnostic.h @@ -0,0 +1,134 @@ +/** + * @file llmaterialdiagnostic.h + * @brief Deterministic fixture and artifact for the indexed material draw. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLMATERIALDIAGNOSTIC_H +#define LL_LLMATERIALDIAGNOSTIC_H + +#include "llmaterialcontract.h" + +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ + +inline constexpr std::uint32_t MATERIAL_DIAGNOSTIC_CHANNELS = 4; +inline constexpr std::size_t MATERIAL_DIAGNOSTIC_PIXEL_COUNT = static_cast(MATERIAL_FRAME_WIDTH) * MATERIAL_FRAME_HEIGHT; +inline constexpr std::size_t MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT = MATERIAL_DIAGNOSTIC_PIXEL_COUNT * MATERIAL_DIAGNOSTIC_CHANNELS; +inline constexpr std::size_t MATERIAL_DIAGNOSTIC_DEPTH_COMPONENT_COUNT = MATERIAL_DIAGNOSTIC_PIXEL_COUNT; + +inline constexpr std::size_t MATERIAL_TEXTURE_COUNT = 3; +inline constexpr std::size_t MATERIAL_TEXTURE_TEXEL_COUNT = 16 + 4 + 1; +inline constexpr std::size_t MATERIAL_TEXTURE_COMPONENT_COUNT = MATERIAL_TEXTURE_TEXEL_COUNT * MATERIAL_DIAGNOSTIC_CHANNELS; +inline constexpr std::array MATERIAL_TEXTURE_MIP_BYTE_OFFSETS{ 0, 64, 80 }; +inline constexpr std::array MATERIAL_TEXTURE_MIP_BYTE_SIZES{ 64, 16, 4 }; + +inline constexpr std::uint32_t MATERIAL_DIAGNOSTIC_FIXTURE_VERSION = 1; +inline constexpr std::uint32_t MATERIAL_ARTIFACT_SCHEMA_VERSION = 1; +inline constexpr std::size_t MATERIAL_ARTIFACT_BYTE_SIZE = 3436; + +inline constexpr float MATERIAL_RGBA8_TOLERANCE = 0.f; +inline constexpr float MATERIAL_RGBA16_TOLERANCE = 0.f; +inline constexpr float MATERIAL_DEPTH24_TOLERANCE = 0.f; + +struct MaterialFixture +{ + Extent2D mExtent{ MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT }; + RowOrigin mRowOrigin = RowOrigin::BottomLeft; + + // Native viewer planar layout. Float arrays and color bytes occupy the + // offsets published by llmaterialcontract.h. + std::array mVertexBytes{}; + std::array mIndices{}; + + // Diffuse, normal, and specular. Each flat array contains the 4x4, 2x2, + // and 1x1 RGBA8 mip levels in that order. + std::array, MATERIAL_TEXTURE_COUNT> mTextureRGBA8{}; + + MaterialParameters mParameters; + + // The depth codes are the loaded contents. Color sentinels make failed + // preflight observable before the canonical color clears run. + std::array mDepth24{}; + std::array mGBuffer0SentinelRGBA8{}; + std::array mGBuffer1SentinelRGBA8{}; + std::array mGBuffer2SentinelRGBA16{}; +}; + +static_assert(sizeof(std::array) == MATERIAL_VERTEX_BUFFER_SIZE); +static_assert(sizeof(std::array) == MATERIAL_INDEX_BUFFER_SIZE); + +MaterialFixture makeMaterialFixture(); +std::uint64_t materialFixtureFingerprint(); + +struct MaterialCase +{ + MaterialInputs mInputs; + FrameSnapshot mFrame; +}; + +MaterialCase makeMaterialCase(); + +float materialUnorm8(std::uint8_t code) noexcept; +float materialUnorm16(std::uint16_t code) noexcept; +float materialDepth24(std::uint32_t code) noexcept; + +struct MaterialArtifact +{ + // Bottom-left row-major values. The three color vectors are RGBA; depth + // has one component per pixel. + std::vector mGBuffer0RGBA8; + std::vector mGBuffer1RGBA8; + std::vector mGBuffer2RGBA16; + std::vector mDepth24; + + friend bool operator==(const MaterialArtifact&, const MaterialArtifact&) = default; +}; + +MaterialArtifact makeMaterialArtifact(); +bool validateMaterialArtifact(const MaterialArtifact& artifact, std::string* error = nullptr); +bool encodeMaterialArtifact(const MaterialArtifact& artifact, std::vector& encoded, std::string* error = nullptr); +bool decodeMaterialArtifact(const std::vector& encoded, MaterialArtifact& artifact, std::string* error = nullptr); + +// The destination must not exist. The writer reserves an unpredictable sibling +// file, writes it completely, then publishes it atomically without replacement. +bool writeMaterialArtifact(const std::filesystem::path& destination, const MaterialArtifact& artifact, std::string* error = nullptr); +bool readMaterialArtifact(const std::filesystem::path& source, MaterialArtifact& artifact, std::string* error = nullptr); + +struct MaterialComparisonStats +{ + bool mComparable = false; + bool mMatch = false; + std::size_t mComparedComponents = 0; + std::size_t mMismatchCount = 0; + double mMaximumAbsoluteError = 0.0; + std::uint32_t mFirstMismatchPlane = 0; + std::size_t mFirstMismatchPixel = 0; + std::uint32_t mFirstMismatchChannel = 0; + float mFirstReference = 0.f; + float mFirstCandidate = 0.f; + float mFirstTolerance = 0.f; + std::string mError; +}; + +MaterialComparisonStats compareMaterialArtifacts(const MaterialArtifact& reference, const MaterialArtifact& candidate); + +} // namespace LLRenderContract + +#endif diff --git a/indra/llrender/llmaterialparametercontract.cpp b/indra/llrender/llmaterialparametercontract.cpp new file mode 100644 index 00000000000..bd0debc2e11 --- /dev/null +++ b/indra/llrender/llmaterialparametercontract.cpp @@ -0,0 +1,240 @@ +/** + * @file llmaterialparametercontract.cpp + * @brief Pure materialization of legacy material shader parameters. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llmaterialparametercontract.h" + +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + + template + bool finite(const std::array& values) noexcept + { + return std::all_of(values.begin(), values.end(), [](float value) { return std::isfinite(value); }); + } + + DrawMatrix4 multiply(const DrawMatrix4& left, const DrawMatrix4& right) noexcept + { + DrawMatrix4 result{}; + for (std::size_t column = 0; column < 4; ++column) + { + for (std::size_t row = 0; row < 4; ++row) + { + result[column * 4 + row] = left[row] * right[column * 4] + left[4 + row] * right[column * 4 + 1] + + left[8 + row] * right[column * 4 + 2] + left[12 + row] * right[column * 4 + 3]; + } + } + return result; + } + + bool affine(const DrawMatrix4& matrix) noexcept + { + return matrix[3] == 0.f && matrix[7] == 0.f && matrix[11] == 0.f && matrix[15] == 1.f; + } + + bool validLinearInverseResidual(const DrawMatrix4& matrix, const std::array& candidate) noexcept + { + // Any singular 3x3 product differs from identity by at least 1/3 in + // one entry. This looser bound admits normal float inversion error + // while remaining far below that singular boundary. + constexpr double MAX_RESIDUAL = 1. / 1024.; + + for (std::size_t column = 0; column < 3; ++column) + { + for (std::size_t row = 0; row < 3; ++row) + { + double forward = 0.; + double reverse = 0.; + for (std::size_t offset = 0; offset < 3; ++offset) + { + forward += static_cast(matrix[offset * 4 + row]) * candidate[column * 3 + offset]; + reverse += static_cast(candidate[offset * 3 + row]) * matrix[column * 4 + offset]; + } + + const double expected = row == column ? 1. : 0.; + if (!std::isfinite(forward) || !std::isfinite(reverse) || std::fabs(forward - expected) > MAX_RESIDUAL || + std::fabs(reverse - expected) > MAX_RESIDUAL) + { + return false; + } + } + } + return true; + } + + bool inverseLinearPart(const DrawMatrix4& matrix, std::array& result) noexcept + { + std::array, 3> augmented{}; + std::array row_scale{}; + for (std::size_t row = 0; row < 3; ++row) + { + for (std::size_t column = 0; column < 3; ++column) + { + augmented[row][column] = matrix[column * 4 + row]; + row_scale[row] = std::max(row_scale[row], std::fabs(augmented[row][column])); + } + if (!std::isfinite(row_scale[row]) || row_scale[row] == 0.) + { + return false; + } + augmented[row][3 + row] = 1.; + } + + const auto normalized = [&](std::size_t row, std::size_t column) + { + return augmented[row][column] / row_scale[row]; + }; + const double determinant = normalized(0, 0) * (normalized(1, 1) * normalized(2, 2) - normalized(1, 2) * normalized(2, 1)) - + normalized(0, 1) * (normalized(1, 0) * normalized(2, 2) - normalized(1, 2) * normalized(2, 0)) + + normalized(0, 2) * (normalized(1, 0) * normalized(2, 1) - normalized(1, 1) * normalized(2, 0)); + constexpr double MIN_NORMALIZED_DETERMINANT = 64. * std::numeric_limits::epsilon(); + if (!std::isfinite(determinant) || std::fabs(determinant) <= MIN_NORMALIZED_DETERMINANT) + { + return false; + } + + for (std::size_t column = 0; column < 3; ++column) + { + std::size_t pivot_row = column; + double pivot_score = std::fabs(augmented[pivot_row][column]) / row_scale[pivot_row]; + for (std::size_t candidate = column + 1; candidate < 3; ++candidate) + { + const double candidate_score = std::fabs(augmented[candidate][column]) / row_scale[candidate]; + if (candidate_score > pivot_score) + { + pivot_row = candidate; + pivot_score = candidate_score; + } + } + + if (!std::isfinite(pivot_score) || pivot_score == 0.) + { + return false; + } + if (pivot_row != column) + { + std::swap(augmented[pivot_row], augmented[column]); + std::swap(row_scale[pivot_row], row_scale[column]); + } + + const double pivot = augmented[column][column]; + for (double& value : augmented[column]) + { + value /= pivot; + if (!std::isfinite(value)) + { + return false; + } + } + + for (std::size_t row = 0; row < 3; ++row) + { + if (row == column) + { + continue; + } + + const double scale = augmented[row][column]; + for (std::size_t offset = 0; offset < 6; ++offset) + { + augmented[row][offset] -= scale * augmented[column][offset]; + if (!std::isfinite(augmented[row][offset])) + { + return false; + } + } + } + } + + for (std::size_t row = 0; row < 3; ++row) + { + for (std::size_t column = 0; column < 3; ++column) + { + const double value = augmented[row][3 + column]; + if (!std::isfinite(value) || std::fabs(value) > std::numeric_limits::max()) + { + return false; + } + result[column * 3 + row] = static_cast(value); + } + } + // A rounded elimination residue can masquerade as a pivot for an + // exactly singular float matrix. Require the returned float packet to + // remain a two-sided inverse instead of relying on pivot magnitude. + return finite(result) && validLinearInverseResidual(matrix, result); + } + + bool productionProfile(const LegacyNormSpecPipelineKey& key) noexcept + { + return key.mTargetProfile == LegacyNormSpecTargetProfile::ModernHDR || + key.mTargetProfile == LegacyNormSpecTargetProfile::Compatibility; + } + +} // namespace + +std::optional materializeLegacyNormSpecWorldParameters(const LegacyNormSpecDrawPacket& packet, + const LegacyNormSpecWorldParameterContext& context) noexcept +{ + if (!validLegacyNormSpecDrawPacket(packet) || !productionProfile(packet.mPipelineKey) || context.mFrame == 0 || + !static_cast(context.mPass) || context.mFrame != packet.mFrame || context.mPass != packet.mPass || + !finite(context.mBaseModelview) || !finite(context.mProjection) || !finite(context.mEyeClipPlane)) + { + return std::nullopt; + } + + MaterialParameters parameters; + parameters.mModelviewMatrix = multiply(context.mBaseModelview, packet.mModelMatrix); + parameters.mModelviewProjectionMatrix = multiply(context.mProjection, parameters.mModelviewMatrix); + if (!finite(parameters.mModelviewMatrix) || !finite(parameters.mModelviewProjectionMatrix)) + { + return std::nullopt; + } + + std::array inverse_linear{}; + if (!affine(parameters.mModelviewMatrix) || !inverseLinearPart(parameters.mModelviewMatrix, inverse_linear)) + { + return std::nullopt; + } + for (std::size_t column = 0; column < 3; ++column) + { + for (std::size_t row = 0; row < 3; ++row) + { + parameters.mNormalMatrix[column * 3 + row] = inverse_linear[row * 3 + column]; + } + } + + parameters.mTextureMatrix0 = packet.mDiffuseTextureMatrix; + parameters.mSpecularColor = packet.mSpecularRGBA; + parameters.mClipPlane = context.mEyeClipPlane; + parameters.mEnvironmentIntensity = packet.mEnvironmentIntensity; + parameters.mEmissiveBrightness = packet.mEmissiveBrightness; + parameters.mMirror = 0.f; + + if (!validMaterialParameters(parameters)) + { + return std::nullopt; + } + return parameters; +} + +} // namespace LLRenderContract diff --git a/indra/llrender/llmaterialparametercontract.h b/indra/llrender/llmaterialparametercontract.h new file mode 100644 index 00000000000..5f2d91b389e --- /dev/null +++ b/indra/llrender/llmaterialparametercontract.h @@ -0,0 +1,48 @@ +/** + * @file llmaterialparametercontract.h + * @brief Pure materialization of legacy material shader parameters. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLMATERIALPARAMETERCONTRACT_H +#define LL_LLMATERIALPARAMETERCONTRACT_H + +#include "lldrawpacketcontract.h" +#include "llmaterialcontract.h" + +#include +#include +#include + +namespace LLRenderContract +{ + +struct LegacyNormSpecWorldParameterContext +{ + std::uint64_t mFrame = 0; + PassId mPass; + // Matrices use the viewer's contiguous column-major convention. + DrawMatrix4 mBaseModelview = DRAW_IDENTITY_MATRIX4; + DrawMatrix4 mProjection = DRAW_IDENTITY_MATRIX4; + std::array mEyeClipPlane{}; + + friend constexpr bool operator==(const LegacyNormSpecWorldParameterContext&, const LegacyNormSpecWorldParameterContext&) = default; +}; + +// Produces the complete owned parameter block for a coherent production draw. +// The composed modelview must be affine; the projection may be projective. +std::optional materializeLegacyNormSpecWorldParameters(const LegacyNormSpecDrawPacket& packet, + const LegacyNormSpecWorldParameterContext& context) noexcept; + +} // namespace LLRenderContract + +#endif // LL_LLMATERIALPARAMETERCONTRACT_H diff --git a/indra/llrender/llrender.h b/indra/llrender/llrender.h index 9c17cf34706..c1cd565cd06 100644 --- a/indra/llrender/llrender.h +++ b/indra/llrender/llrender.h @@ -225,6 +225,8 @@ class LLTexUnit eTextureType getCurrType(void) { return mCurrTexType; } + bool getHasMipMaps(void) const { return mHasMipMaps; } + void setHasMipMaps(bool hasMips) { mHasMipMaps = hasMips; } protected: diff --git a/indra/llrender/llrendercontract.cpp b/indra/llrender/llrendercontract.cpp new file mode 100644 index 00000000000..988b540a54e --- /dev/null +++ b/indra/llrender/llrendercontract.cpp @@ -0,0 +1,1186 @@ +/** + * @file llrendercontract.cpp + * @brief Validation for API-neutral renderer work descriptions. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#include "llrendercontract.h" + +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + + template + bool addWouldOverflow(Value left, Value right) + { + return right > std::numeric_limits::max() - left; + } + + template + bool multiplyWouldOverflow(Value left, Value right) + { + return left != 0 && right > std::numeric_limits::max() / left; + } + + template + bool validEnum(Enum value, Enum last) + { + static_assert(std::is_enum_v); + using Underlying = std::underlying_type_t; + const Underlying number = static_cast(value); + return number >= 0 && number <= static_cast(last); + } + + std::uint32_t bytesPerPixel(PixelFormat format) + { + switch (format) + { + case PixelFormat::R8Unorm: + return 1; + case PixelFormat::RG8Unorm: + return 2; + case PixelFormat::RGB8Unorm: + return 3; + case PixelFormat::R16Float: + return 2; + case PixelFormat::RGBA8Unorm: + case PixelFormat::RGBA8Srgb: + case PixelFormat::RGB10A2Unorm: + case PixelFormat::Depth24Unorm: + case PixelFormat::Depth32Float: + return 4; + case PixelFormat::RGBA16Unorm: + return 8; + case PixelFormat::RGB16Float: + return 6; + case PixelFormat::RGBA16Float: + return 8; + } + return 0; + } + + bool isDepthFormat(PixelFormat format) + { + return format == PixelFormat::Depth24Unorm || format == PixelFormat::Depth32Float; + } + + std::uint32_t maxMipLevels(Extent2D extent) + { + std::uint32_t levels = 0; + while (extent.mWidth != 0 || extent.mHeight != 0) + { + ++levels; + extent.mWidth /= 2; + extent.mHeight /= 2; + } + return levels; + } + + std::uint32_t vertexFormatSize(VertexFormat format) + { + switch (format) + { + case VertexFormat::Float2: + return 8; + case VertexFormat::Float3: + return 12; + case VertexFormat::Float4: + return 16; + case VertexFormat::UNorm8x4: + return 4; + } + return 0; + } + + std::uint32_t indexSize(IndexType type) + { + switch (type) + { + case IndexType::UInt16: + return 2; + case IndexType::UInt32: + return 4; + } + return 0; + } + + Extent2D mipExtent(const ImageResource& image, std::uint32_t mip_level) + { + Extent2D extent = image.mExtent; + for (std::uint32_t mip = 0; mip < mip_level; ++mip) + { + extent.mWidth = std::max(1u, extent.mWidth / 2); + extent.mHeight = std::max(1u, extent.mHeight / 2); + } + return extent; + } + + bool contains(const std::vector& values, std::uint32_t value) + { + return std::find(values.begin(), values.end(), value) != values.end(); + } + + template + bool containsDuplicate(const std::vector& values) + { + for (std::size_t index = 0; index < values.size(); ++index) + { + if (std::find(values.begin() + index + 1, values.end(), values[index]) != values.end()) + { + return true; + } + } + return false; + } + + class Validator + { + public: + explicit Validator(const FrameSnapshot& frame) : mFrame(frame) {} + + ValidationResult run() + { + validateResources(); + validateUploads(); + validatePasses(); + validateReleases(); + validateFrameLifetimes(); + return std::move(mResult); + } + + private: + template + static const Descriptor* find(const std::vector& resources, ResourceHandleType handle) + { + const auto found = std::find_if(resources.begin(), resources.end(), + [handle](const Descriptor& resource) { return resource.mHandle == handle; }); + return found == resources.end() ? nullptr : &*found; + } + + template + void validateResourceHandles(const std::vector& resources, const std::string& path) + { + for (std::size_t index = 0; index < resources.size(); ++index) + { + if (!resources[index].mHandle) + { + error(ValidationCode::InvalidHandle, path + "[" + std::to_string(index) + "]", + "resource handle must have a non-zero index and generation"); + } + for (std::size_t prior = 0; prior < index; ++prior) + { + if (resources[prior].mHandle == resources[index].mHandle) + { + error(ValidationCode::DuplicateResource, path + "[" + std::to_string(index) + "]", + "resource handle is declared more than once"); + break; + } + } + } + } + + void error(ValidationCode code, std::string path, std::string message) + { + mResult.mErrors.push_back({ code, std::move(path), std::move(message) }); + } + + bool validBytes(const ByteRange& bytes, const std::string& path) + { + if (!bytes.mStorage) + { + error(ValidationCode::InvalidBinding, path, "byte storage is not owned"); + return false; + } + if (bytes.mOffset > bytes.mStorage->size() || bytes.mSize > bytes.mStorage->size() - bytes.mOffset) + { + error(ValidationCode::OutOfBounds, path, "byte range exceeds its owned storage"); + return false; + } + return true; + } + + bool validSubresource(const ImageResource& image, const ImageSubresource& subresource, const std::string& path) + { + if (subresource.mMipLevel >= image.mMipLevels || subresource.mArrayLayer >= image.mArrayLayers) + { + error(ValidationCode::OutOfBounds, path, "image subresource is outside the declared image"); + return false; + } + return true; + } + + bool validSubresourceRange(const ImageResource& image, const ImageSubresourceRange& range, const std::string& path) + { + if (range.mMipLevelCount == 0 || range.mArrayLayerCount == 0 || range.mBaseMipLevel >= image.mMipLevels || + range.mBaseArrayLayer >= image.mArrayLayers || range.mMipLevelCount > image.mMipLevels - range.mBaseMipLevel || + range.mArrayLayerCount > image.mArrayLayers - range.mBaseArrayLayer) + { + error(ValidationCode::OutOfBounds, path, "image subresource range is outside the declared image"); + return false; + } + return true; + } + + template + static void forEachSubresource(const ImageSubresourceRange& range, Function&& function) + { + for (std::uint32_t layer = range.mBaseArrayLayer; layer < range.mBaseArrayLayer + range.mArrayLayerCount; ++layer) + { + for (std::uint32_t mip = range.mBaseMipLevel; mip < range.mBaseMipLevel + range.mMipLevelCount; ++mip) + { + function(ImageSubresource{ mip, layer }); + } + } + } + + static bool rangesOverlap(const ImageSubresourceRange& left, const ImageSubresourceRange& right) + { + const std::uint64_t left_mip_end = static_cast(left.mBaseMipLevel) + left.mMipLevelCount; + const std::uint64_t right_mip_end = static_cast(right.mBaseMipLevel) + right.mMipLevelCount; + const std::uint64_t left_layer_end = static_cast(left.mBaseArrayLayer) + left.mArrayLayerCount; + const std::uint64_t right_layer_end = static_cast(right.mBaseArrayLayer) + right.mArrayLayerCount; + return left.mBaseMipLevel < right_mip_end && right.mBaseMipLevel < left_mip_end && left.mBaseArrayLayer < right_layer_end && + right.mBaseArrayLayer < left_layer_end; + } + + static bool rangeContains(const ImageSubresourceRange& outer, const ImageSubresourceRange& inner) + { + const std::uint64_t outer_mip_end = static_cast(outer.mBaseMipLevel) + outer.mMipLevelCount; + const std::uint64_t inner_mip_end = static_cast(inner.mBaseMipLevel) + inner.mMipLevelCount; + const std::uint64_t outer_layer_end = static_cast(outer.mBaseArrayLayer) + outer.mArrayLayerCount; + const std::uint64_t inner_layer_end = static_cast(inner.mBaseArrayLayer) + inner.mArrayLayerCount; + return outer.mBaseMipLevel <= inner.mBaseMipLevel && outer_mip_end >= inner_mip_end && + outer.mBaseArrayLayer <= inner.mBaseArrayLayer && outer_layer_end >= inner_layer_end; + } + + static ImageSubresourceRange singleSubresource(ImageSubresource subresource) + { + return { subresource.mMipLevel, 1, subresource.mArrayLayer, 1 }; + } + + void validateResources() + { + if (mFrame.mFrame == 0) + { + error(ValidationCode::InvalidFrame, "frame", "frame serial must be non-zero"); + } + + validateResourceHandles(mFrame.mBuffers, "buffers"); + validateResourceHandles(mFrame.mImages, "images"); + validateResourceHandles(mFrame.mSamplers, "samplers"); + validateResourceHandles(mFrame.mPipelines, "pipelines"); + + for (std::size_t index = 0; index < mFrame.mBuffers.size(); ++index) + { + if (mFrame.mBuffers[index].mSize == 0 || !validEnum(mFrame.mBuffers[index].mLifetime, ResourceLifetime::Frame)) + { + error(ValidationCode::InvalidResource, "buffers[" + std::to_string(index) + "]", "buffer size must be non-zero"); + } + } + + for (std::size_t index = 0; index < mFrame.mImages.size(); ++index) + { + const ImageResource& image = mFrame.mImages[index]; + if (image.mExtent.mWidth == 0 || image.mExtent.mHeight == 0 || image.mMipLevels == 0 || image.mArrayLayers == 0 || + image.mSamples == 0 || image.mMipLevels > maxMipLevels(image.mExtent) || + !validEnum(image.mFormat, PixelFormat::RGB16Float) || !validEnum(image.mLifetime, ResourceLifetime::Frame)) + { + error(ValidationCode::InvalidResource, "images[" + std::to_string(index) + "]", + "image dimensions, subresource counts, and samples must be non-zero"); + } + } + + for (std::size_t index = 0; index < mFrame.mPipelines.size(); ++index) + { + validatePipeline(mFrame.mPipelines[index], "pipelines[" + std::to_string(index) + "]"); + } + + for (std::size_t index = 0; index < mFrame.mSamplers.size(); ++index) + { + const SamplerResource& sampler = mFrame.mSamplers[index]; + if (!std::isfinite(sampler.mMaxAnisotropy) || sampler.mMaxAnisotropy < 1.f || + !validEnum(sampler.mMinFilter, Filter::Linear) || !validEnum(sampler.mMagFilter, Filter::Linear) || + !validEnum(sampler.mMipFilter, MipFilter::Linear) || !validEnum(sampler.mAddressU, AddressMode::Mirror) || + !validEnum(sampler.mAddressV, AddressMode::Mirror) || !validEnum(sampler.mLifetime, ResourceLifetime::Frame)) + { + error(ValidationCode::InvalidResource, "samplers[" + std::to_string(index) + "]", + "sampler anisotropy must be finite and at least one"); + } + } + } + + void validatePipeline(const PipelineResource& pipeline, const std::string& path) + { + if (pipeline.mProgram.mName.empty() || pipeline.mSamples == 0 || pipeline.mColorTargets.empty() || + !validEnum(pipeline.mTopology, PrimitiveTopology::TriangleList) || !validEnum(pipeline.mCullMode, CullMode::Back) || + !validEnum(pipeline.mFrontFace, FrontFace::CounterClockwise) || + !validEnum(pipeline.mDepthCompare, CompareOp::LessOrEqual) || !validEnum(pipeline.mLifetime, ResourceLifetime::Frame)) + { + error(ValidationCode::InvalidResource, path, + "graphics pipeline must declare a program, samples, and at least one color target"); + } + if (pipeline.mDepthWriteEnabled && !pipeline.mDepthTestEnabled) + { + error(ValidationCode::InvalidResource, path, "depth writes require depth testing in this contract"); + } + if ((pipeline.mDepthTestEnabled || pipeline.mDepthWriteEnabled) && !pipeline.mDepthFormat) + { + error(ValidationCode::InvalidResource, path, "depth state requires a depth format"); + } + if (pipeline.mDepthFormat && + (!validEnum(*pipeline.mDepthFormat, PixelFormat::RGB16Float) || !isDepthFormat(*pipeline.mDepthFormat))) + { + error(ValidationCode::InvalidResource, path, "pipeline depth format is not a depth format"); + } + for (const ColorTargetState& target : pipeline.mColorTargets) + { + if (!validEnum(target.mFormat, PixelFormat::RGB16Float) || isDepthFormat(target.mFormat) || + (target.mWriteMask & ~0xfu) != 0) + { + error(ValidationCode::InvalidResource, path, "color target format or write mask is invalid"); + } + } + + std::vector vertex_bindings; + for (const VertexBindingLayout& binding : pipeline.mVertexBindings) + { + vertex_bindings.push_back(binding.mBinding); + if (binding.mStride == 0) + { + error(ValidationCode::InvalidBinding, path, "pipeline vertex binding stride must be non-zero"); + } + } + + std::vector semantics; + for (const VertexAttribute& attribute : pipeline.mVertexAttributes) + { + semantics.push_back(attribute.mSemantic); + if (!validEnum(attribute.mSemantic, VertexSemantic::TexCoord2) || !validEnum(attribute.mFormat, VertexFormat::UNorm8x4)) + { + error(ValidationCode::InvalidBinding, path, "pipeline vertex attribute enum is invalid"); + } + const auto binding = + std::find_if(pipeline.mVertexBindings.begin(), pipeline.mVertexBindings.end(), + [&attribute](const VertexBindingLayout& layout) { return layout.mBinding == attribute.mBinding; }); + if (binding == pipeline.mVertexBindings.end()) + { + error(ValidationCode::InvalidBinding, path, "pipeline vertex attribute names an absent binding layout"); + } + else if (attribute.mOffset > binding->mStride || vertexFormatSize(attribute.mFormat) > binding->mStride - attribute.mOffset) + { + error(ValidationCode::InvalidBinding, path, "pipeline vertex attribute does not fit its binding stride"); + } + } + std::vector parameter_bindings; + for (const ParameterLayout& parameter : pipeline.mParameterBindings) + { + parameter_bindings.push_back(parameter.mBinding); + if (parameter.mSize == 0) + { + error(ValidationCode::InvalidBinding, path, "pipeline parameter size must be non-zero"); + } + } + if (containsDuplicate(vertex_bindings) || containsDuplicate(semantics) || containsDuplicate(pipeline.mSampledImageBindings) || + containsDuplicate(parameter_bindings)) + { + error(ValidationCode::InvalidBinding, path, "pipeline layout contains duplicate bindings"); + } + } + + void validateUploads() + { + for (std::size_t index = 0; index < mFrame.mUploads.size(); ++index) + { + const TextureUpload& upload = mFrame.mUploads[index]; + const std::string path = "uploads[" + std::to_string(index) + "]"; + const ImageResource* image = find(mFrame.mImages, upload.mDestination); + + if (!upload.mDestination) + { + error(ValidationCode::InvalidHandle, path, "upload destination handle is invalid"); + continue; + } + if (!image) + { + error(ValidationCode::MissingResource, path, "upload destination is not declared"); + continue; + } + if (upload.mRevision == 0) + { + error(ValidationCode::InvalidUpload, path, "upload revision must be non-zero"); + } + if (!validEnum(upload.mSourceFormat, PixelFormat::RGB16Float) || !validEnum(upload.mRowOrigin, RowOrigin::BottomLeft) || + !validEnum(upload.mMipGeneration, MipGeneration::GenerateRemaining) || + !validEnum(upload.mBefore, ImageState::DepthAttachment) || !validEnum(upload.mDuring, ImageState::DepthAttachment) || + !validEnum(upload.mAfter, ImageState::DepthAttachment)) + { + error(ValidationCode::InvalidUpload, path, "upload enum value is outside the contract domain"); + continue; + } + const std::uint64_t expected_logical_width = + upload.mResidentDiscard <= 31 ? static_cast(image->mExtent.mWidth) << upload.mResidentDiscard : 0; + const std::uint64_t expected_logical_height = + upload.mResidentDiscard <= 31 ? static_cast(image->mExtent.mHeight) << upload.mResidentDiscard : 0; + if (upload.mResidentDiscard > 31 || expected_logical_width > std::numeric_limits::max() || + expected_logical_height > std::numeric_limits::max() || + upload.mLogicalExtent.mWidth != expected_logical_width || upload.mLogicalExtent.mHeight != expected_logical_height) + { + error(ValidationCode::InvalidUpload, path, "upload logical extent must equal resident extent shifted by discard"); + } + if (!validSubresource(*image, upload.mSubresource, path)) + { + continue; + } + if (isDepthFormat(upload.mSourceFormat) || upload.mSourceFormat != image->mFormat) + { + error(ValidationCode::InvalidUpload, path, "upload source format must match a non-depth destination format"); + } + if (upload.mDuring != ImageState::TransferDestination || upload.mAfter == ImageState::Undefined) + { + error(ValidationCode::InvalidState, path, + "upload must use transfer-destination state and publish a defined final state"); + } + if (upload.mMipGeneration == MipGeneration::GenerateRemaining && upload.mSubresource.mMipLevel + 1 >= image->mMipLevels) + { + error(ValidationCode::InvalidUpload, path, "mip generation requires destination levels below the uploaded level"); + } + + const Extent2D destination_extent = mipExtent(*image, upload.mSubresource.mMipLevel); + if (upload.mExtent.mWidth == 0 || upload.mExtent.mHeight == 0 || upload.mOffset.mX > destination_extent.mWidth || + upload.mOffset.mY > destination_extent.mHeight || + upload.mExtent.mWidth > destination_extent.mWidth - upload.mOffset.mX || + upload.mExtent.mHeight > destination_extent.mHeight - upload.mOffset.mY) + { + error(ValidationCode::OutOfBounds, path, "upload region exceeds the destination image"); + } + + const std::uint64_t tight_row = static_cast(upload.mExtent.mWidth) * bytesPerPixel(upload.mSourceFormat); + if (upload.mRowPitch < tight_row) + { + error(ValidationCode::InvalidUpload, path, "upload row pitch is smaller than one row"); + } + else if (validBytes(upload.mPixels, path + ".pixels") && upload.mExtent.mHeight != 0) + { + const std::uint64_t preceding_rows = upload.mExtent.mHeight - 1; + if (multiplyWouldOverflow(preceding_rows, static_cast(upload.mRowPitch)) || + addWouldOverflow(preceding_rows * upload.mRowPitch, tight_row) || + preceding_rows * upload.mRowPitch + tight_row > upload.mPixels.mSize) + { + error(ValidationCode::OutOfBounds, path + ".pixels", "upload byte range does not contain the declared rows"); + } + } + + for (std::size_t prior = 0; prior < index; ++prior) + { + if (mFrame.mUploads[prior].mDestination == upload.mDestination && mFrame.mUploads[prior].mRevision >= upload.mRevision) + { + error(ValidationCode::InvalidUpload, path, + "upload revisions for one destination must increase in declaration order"); + break; + } + } + + transition(upload.mDestination, upload.mSubresource, upload.mBefore, upload.mAfter, path); + if (upload.mMipGeneration == MipGeneration::GenerateRemaining) + { + for (std::uint32_t mip = upload.mSubresource.mMipLevel + 1; mip < image->mMipLevels; ++mip) + { + transition(upload.mDestination, { mip, upload.mSubresource.mArrayLayer }, ImageState::Undefined, upload.mAfter, + path); + } + } + } + } + + void validatePasses() + { + std::vector seen; + for (std::size_t index = 0; index < mFrame.mPasses.size(); ++index) + { + const RenderPass& pass = mFrame.mPasses[index]; + const std::string path = "passes[" + std::to_string(index) + "]"; + + if (!pass.mId) + { + error(ValidationCode::InvalidPass, path, "pass id must be non-zero"); + } + else if (std::find(seen.begin(), seen.end(), pass.mId) != seen.end()) + { + error(ValidationCode::InvalidPass, path, "pass id is duplicated"); + } + + for (PassId dependency : pass.mDependencies) + { + if (!dependency || std::find(seen.begin(), seen.end(), dependency) == seen.end()) + { + error(ValidationCode::InvalidDependency, path, "pass dependency must name an earlier pass"); + } + } + if (containsDuplicate(pass.mDependencies)) + { + error(ValidationCode::InvalidDependency, path, "pass dependency is duplicated"); + } + + validatePassShape(pass, path); + validatePassAccesses(pass, path); + validateAttachments(pass, path); + for (std::size_t draw_index = 0; draw_index < pass.mDraws.size(); ++draw_index) + { + const std::string draw_path = path + ".draws[" + std::to_string(draw_index) + "]"; + std::visit([&](const auto& draw) { validateDraw(pass, draw, draw_path); }, pass.mDraws[draw_index]); + } + + seen.push_back(pass.mId); + } + } + + void validatePassShape(const RenderPass& pass, const std::string& path) + { + if (pass.mExtent.mWidth == 0 || pass.mExtent.mHeight == 0 || pass.mDraws.empty() || pass.mColorAttachments.empty()) + { + error(ValidationCode::InvalidPass, path, "render pass needs an extent, color attachment, and draw"); + } + + const Viewport& viewport = pass.mViewport; + if (!std::isfinite(viewport.mX) || !std::isfinite(viewport.mY) || !std::isfinite(viewport.mWidth) || + !std::isfinite(viewport.mHeight) || !std::isfinite(viewport.mMinDepth) || !std::isfinite(viewport.mMaxDepth) || + viewport.mX < 0.f || viewport.mY < 0.f || viewport.mWidth <= 0.f || viewport.mHeight <= 0.f || + viewport.mX + viewport.mWidth > pass.mExtent.mWidth || viewport.mY + viewport.mHeight > pass.mExtent.mHeight || + viewport.mMinDepth < 0.f || viewport.mMaxDepth > 1.f || viewport.mMinDepth > viewport.mMaxDepth) + { + error(ValidationCode::InvalidPass, path + ".viewport", "viewport must be finite and inside the pass extent"); + } + + const Scissor& scissor = pass.mScissor; + if (scissor.mWidth == 0 || scissor.mHeight == 0 || scissor.mX > pass.mExtent.mWidth || scissor.mY > pass.mExtent.mHeight || + scissor.mWidth > pass.mExtent.mWidth - scissor.mX || scissor.mHeight > pass.mExtent.mHeight - scissor.mY) + { + error(ValidationCode::InvalidPass, path + ".scissor", "scissor must be inside the pass extent"); + } + } + + void validatePassAccesses(const RenderPass& pass, const std::string& path) + { + for (std::size_t index = 0; index < pass.mBufferAccesses.size(); ++index) + { + const BufferAccess& access = pass.mBufferAccesses[index]; + if (!validEnum(access.mKind, BufferAccessKind::IndexRead)) + { + error(ValidationCode::InvalidBinding, path + ".bufferAccesses[" + std::to_string(index) + "]", + "buffer access kind is outside the contract domain"); + } + if (!access.mBuffer) + { + error(ValidationCode::InvalidHandle, path + ".bufferAccesses[" + std::to_string(index) + "]", + "buffer access handle is invalid"); + } + else if (!find(mFrame.mBuffers, access.mBuffer)) + { + error(ValidationCode::MissingResource, path + ".bufferAccesses[" + std::to_string(index) + "]", + "buffer access names an undeclared generation"); + } + } + + for (std::size_t index = 0; index < pass.mImageAccesses.size(); ++index) + { + const ImageAccess& access = pass.mImageAccesses[index]; + const std::string access_path = path + ".imageAccesses[" + std::to_string(index) + "]"; + const ImageResource* image = find(mFrame.mImages, access.mImage); + if (!access.mImage) + { + error(ValidationCode::InvalidHandle, access_path, "image access handle is invalid"); + continue; + } + if (!image) + { + error(ValidationCode::MissingResource, access_path, "image access names an undeclared generation"); + continue; + } + if (!validEnum(access.mKind, ImageAccessKind::DepthAttachmentReadWrite) || + !validEnum(access.mBefore, ImageState::DepthAttachment) || !validEnum(access.mDuring, ImageState::DepthAttachment) || + !validEnum(access.mAfter, ImageState::DepthAttachment)) + { + error(ValidationCode::InvalidState, access_path, "image access enum value is outside the contract domain"); + continue; + } + if (!validSubresourceRange(*image, access.mRange, access_path)) + { + continue; + } + + ImageState required_state = ImageState::ShaderRead; + switch (access.mKind) + { + case ImageAccessKind::SampledRead: + required_state = ImageState::ShaderRead; + if (access.mBefore == ImageState::Undefined) + { + error(ValidationCode::InvalidState, access_path, "sampled reads cannot discard prior image contents"); + } + break; + case ImageAccessKind::ColorAttachmentWrite: + required_state = ImageState::ColorAttachment; + break; + case ImageAccessKind::DepthAttachmentReadWrite: + required_state = ImageState::DepthAttachment; + break; + } + if (access.mDuring != required_state || + (access.mKind == ImageAccessKind::SampledRead && access.mAfter == ImageState::Undefined)) + { + error(ValidationCode::InvalidState, access_path, "image access kind and declared states disagree"); + } + + for (std::size_t prior = 0; prior < index; ++prior) + { + if (pass.mImageAccesses[prior].mImage == access.mImage && + rangesOverlap(pass.mImageAccesses[prior].mRange, access.mRange)) + { + error(ValidationCode::InvalidPass, access_path, "one pass may declare an image subresource only once"); + break; + } + } + forEachSubresource(access.mRange, [&](ImageSubresource subresource) + { transition(access.mImage, subresource, access.mBefore, access.mAfter, access_path); }); + } + } + + void validateAttachments(const RenderPass& pass, const std::string& path) + { + for (std::size_t index = 0; index < pass.mColorAttachments.size(); ++index) + { + const ColorAttachment& attachment = pass.mColorAttachments[index]; + const std::string attachment_path = path + ".colorAttachments[" + std::to_string(index) + "]"; + const ImageResource* image = find(mFrame.mImages, attachment.mImage); + if (!validEnum(attachment.mLoad, LoadOp::DontCare) || !validEnum(attachment.mStore, StoreOp::DontCare) || + (attachment.mLoad == LoadOp::Clear && + (!std::isfinite(attachment.mClear.mRed) || !std::isfinite(attachment.mClear.mGreen) || + !std::isfinite(attachment.mClear.mBlue) || !std::isfinite(attachment.mClear.mAlpha)))) + { + error(ValidationCode::InvalidPass, attachment_path, "color attachment operation or clear value is invalid"); + } + if (!image) + { + error(ValidationCode::MissingResource, attachment_path, "color attachment image is not declared"); + continue; + } + if (isDepthFormat(image->mFormat) || !validSubresource(*image, attachment.mSubresource, attachment_path)) + { + error(ValidationCode::InvalidResource, attachment_path, "color attachment must name a color image subresource"); + continue; + } + const Extent2D extent = mipExtent(*image, attachment.mSubresource.mMipLevel); + if (extent.mWidth < pass.mExtent.mWidth || extent.mHeight < pass.mExtent.mHeight) + { + error(ValidationCode::OutOfBounds, attachment_path, "color attachment is smaller than the render pass"); + } + const ImageAccess* access = findImageAccess(pass, attachment.mImage, singleSubresource(attachment.mSubresource), + ImageAccessKind::ColorAttachmentWrite); + if (!access) + { + error(ValidationCode::MissingAccess, attachment_path, "color attachment write is not declared"); + } + else if (attachment.mLoad == LoadOp::Load && access->mBefore == ImageState::Undefined) + { + error(ValidationCode::InvalidState, attachment_path, "a load attachment cannot begin from undefined contents"); + } + else if ((attachment.mStore == StoreOp::DontCare) != (access->mAfter == ImageState::Undefined)) + { + error(ValidationCode::InvalidState, attachment_path, + "attachment after-state must be undefined exactly when its contents are discarded"); + } + } + + if (pass.mDepthAttachment) + { + const DepthAttachment& attachment = *pass.mDepthAttachment; + const std::string attachment_path = path + ".depthAttachment"; + const ImageResource* image = find(mFrame.mImages, attachment.mImage); + if (!validEnum(attachment.mLoad, LoadOp::DontCare) || !validEnum(attachment.mStore, StoreOp::DontCare) || + (attachment.mLoad == LoadOp::Clear && + (!std::isfinite(attachment.mClearDepth) || attachment.mClearDepth < 0.f || attachment.mClearDepth > 1.f))) + { + error(ValidationCode::InvalidPass, attachment_path, "depth attachment operation or clear value is invalid"); + } + if (!image || !isDepthFormat(image->mFormat)) + { + error(ValidationCode::MissingResource, attachment_path, "depth attachment must name a declared depth image"); + } + else + { + if (validSubresource(*image, attachment.mSubresource, attachment_path)) + { + const Extent2D extent = mipExtent(*image, attachment.mSubresource.mMipLevel); + if (extent.mWidth < pass.mExtent.mWidth || extent.mHeight < pass.mExtent.mHeight) + { + error(ValidationCode::OutOfBounds, attachment_path, "depth attachment is smaller than the render pass"); + } + } + } + const ImageAccess* access = findImageAccess(pass, attachment.mImage, singleSubresource(attachment.mSubresource), + ImageAccessKind::DepthAttachmentReadWrite); + if (!access) + { + error(ValidationCode::MissingAccess, attachment_path, "depth attachment access is not declared"); + } + else if (attachment.mLoad == LoadOp::Load && access->mBefore == ImageState::Undefined) + { + error(ValidationCode::InvalidState, attachment_path, "a load attachment cannot begin from undefined contents"); + } + else if ((attachment.mStore == StoreOp::DontCare) != (access->mAfter == ImageState::Undefined)) + { + error(ValidationCode::InvalidState, attachment_path, + "attachment after-state must be undefined exactly when its contents are discarded"); + } + } + } + + const ImageAccess* findImageAccess(const RenderPass& pass, ImageHandle image, ImageSubresourceRange range, + ImageAccessKind kind) const + { + const auto found = + std::find_if(pass.mImageAccesses.begin(), pass.mImageAccesses.end(), [image, range, kind](const ImageAccess& access) + { return access.mImage == image && rangeContains(access.mRange, range) && access.mKind == kind; }); + return found == pass.mImageAccesses.end() ? nullptr : &*found; + } + + bool hasBufferAccess(const RenderPass& pass, BufferHandle buffer, BufferAccessKind kind) const + { + return std::find_if(pass.mBufferAccesses.begin(), pass.mBufferAccesses.end(), [buffer, kind](const BufferAccess& access) + { return access.mBuffer == buffer && access.mKind == kind; }) != pass.mBufferAccesses.end(); + } + + const PipelineResource* validateDrawResources(const RenderPass& pass, const DrawResources& resources, const std::string& path) + { + const PipelineResource* pipeline = find(mFrame.mPipelines, resources.mPipeline); + if (!resources.mPipeline) + { + error(ValidationCode::InvalidHandle, path, "draw pipeline handle is invalid"); + return nullptr; + } + if (!pipeline) + { + error(ValidationCode::MissingResource, path, "draw pipeline names an undeclared generation"); + return nullptr; + } + + if (pipeline->mColorTargets.size() != pass.mColorAttachments.size()) + { + error(ValidationCode::InvalidBinding, path, "pipeline color layout does not match the render pass"); + } + else + { + for (std::size_t index = 0; index < pipeline->mColorTargets.size(); ++index) + { + const ImageResource* image = find(mFrame.mImages, pass.mColorAttachments[index].mImage); + if (image && (image->mFormat != pipeline->mColorTargets[index].mFormat || image->mSamples != pipeline->mSamples)) + { + error(ValidationCode::InvalidBinding, path, "pipeline color format or sample count does not match the attachment"); + } + } + } + + if (pipeline->mDepthFormat.has_value() != pass.mDepthAttachment.has_value()) + { + error(ValidationCode::InvalidBinding, path, "pipeline depth layout does not match the render pass"); + } + else if (pipeline->mDepthFormat && pass.mDepthAttachment) + { + const ImageResource* image = find(mFrame.mImages, pass.mDepthAttachment->mImage); + if (image && (image->mFormat != *pipeline->mDepthFormat || image->mSamples != pipeline->mSamples)) + { + error(ValidationCode::InvalidBinding, path, "pipeline depth format or sample count does not match the attachment"); + } + } + + validateSampledBindings(pass, *pipeline, resources.mSampledImages, path); + validateParameterBindings(*pipeline, resources.mParameters, path); + validateVertexBindings(pass, *pipeline, resources.mVertexBuffers, path); + return pipeline; + } + + void validateSampledBindings(const RenderPass& pass, const PipelineResource& pipeline, + const std::vector& bindings, const std::string& path) + { + std::vector supplied; + for (std::size_t index = 0; index < bindings.size(); ++index) + { + const SampledImageBinding& binding = bindings[index]; + const std::string binding_path = path + ".sampledImages[" + std::to_string(index) + "]"; + supplied.push_back(binding.mBinding); + if (!contains(pipeline.mSampledImageBindings, binding.mBinding)) + { + error(ValidationCode::InvalidBinding, binding_path, "sampled-image binding is absent from the pipeline layout"); + } + const ImageResource* image = find(mFrame.mImages, binding.mImage); + if (!image || !find(mFrame.mSamplers, binding.mSampler)) + { + error(ValidationCode::MissingResource, binding_path, "sampled image or sampler is not declared"); + } + else + { + validSubresourceRange(*image, binding.mRange, binding_path); + } + if (!findImageAccess(pass, binding.mImage, binding.mRange, ImageAccessKind::SampledRead)) + { + error(ValidationCode::MissingAccess, binding_path, "sampled image read is not declared by the pass"); + } + } + if (containsDuplicate(supplied)) + { + error(ValidationCode::InvalidBinding, path, "sampled-image binding is supplied more than once"); + } + for (std::uint32_t required : pipeline.mSampledImageBindings) + { + if (!contains(supplied, required)) + { + error(ValidationCode::InvalidBinding, path, "required sampled-image binding is missing"); + } + } + } + + void validateParameterBindings(const PipelineResource& pipeline, const std::vector& bindings, + const std::string& path) + { + std::vector supplied; + for (std::size_t index = 0; index < bindings.size(); ++index) + { + const ParameterBinding& binding = bindings[index]; + const std::string binding_path = path + ".parameters[" + std::to_string(index) + "]"; + supplied.push_back(binding.mBinding); + const auto layout = + std::find_if(pipeline.mParameterBindings.begin(), pipeline.mParameterBindings.end(), + [&binding](const ParameterLayout& parameter) { return parameter.mBinding == binding.mBinding; }); + if (layout == pipeline.mParameterBindings.end()) + { + error(ValidationCode::InvalidBinding, binding_path, "parameter binding is absent from the pipeline layout"); + } + else if (binding.mBytes.mSize != layout->mSize) + { + error(ValidationCode::InvalidBinding, binding_path, "parameter byte count does not match the pipeline layout"); + } + if (!validBytes(binding.mBytes, binding_path) || binding.mBytes.mSize == 0) + { + error(ValidationCode::InvalidBinding, binding_path, "parameter binding must own a non-empty byte range"); + } + } + if (containsDuplicate(supplied)) + { + error(ValidationCode::InvalidBinding, path, "parameter binding is supplied more than once"); + } + for (const ParameterLayout& required : pipeline.mParameterBindings) + { + if (!contains(supplied, required.mBinding)) + { + error(ValidationCode::InvalidBinding, path, "required parameter binding is missing"); + } + } + } + + void validateVertexBindings(const RenderPass& pass, const PipelineResource& pipeline, + const std::vector& bindings, const std::string& path) + { + std::vector required; + for (const VertexBindingLayout& binding : pipeline.mVertexBindings) + { + required.push_back(binding.mBinding); + } + + std::vector supplied; + for (std::size_t index = 0; index < bindings.size(); ++index) + { + const VertexBufferBinding& binding = bindings[index]; + const std::string binding_path = path + ".vertexBuffers[" + std::to_string(index) + "]"; + supplied.push_back(binding.mBinding); + const BufferResource* buffer = find(mFrame.mBuffers, binding.mBuffer); + if (!contains(required, binding.mBinding)) + { + error(ValidationCode::InvalidBinding, binding_path, "vertex binding is absent from the pipeline layout"); + } + if (!buffer) + { + error(ValidationCode::MissingResource, binding_path, "vertex buffer is not declared"); + } + else if (binding.mOffset >= buffer->mSize) + { + error(ValidationCode::OutOfBounds, binding_path, "vertex buffer offset does not describe accessible data"); + } + if (!hasBufferAccess(pass, binding.mBuffer, BufferAccessKind::VertexRead)) + { + error(ValidationCode::MissingAccess, binding_path, "vertex-buffer read is not declared by the pass"); + } + } + if (containsDuplicate(supplied)) + { + error(ValidationCode::InvalidBinding, path, "vertex binding is supplied more than once"); + } + for (std::uint32_t binding : required) + { + if (!contains(supplied, binding)) + { + error(ValidationCode::InvalidBinding, path, "required vertex binding is missing"); + } + } + } + + void validateDraw(const RenderPass& pass, const Draw& draw, const std::string& path) + { + const PipelineResource* pipeline = validateDrawResources(pass, draw.mResources, path); + if (draw.mVertexCount == 0 || draw.mInstanceCount == 0) + { + error(ValidationCode::InvalidBinding, path, "non-indexed draw counts must be non-zero"); + return; + } + if (!pipeline || pipeline->mVertexAttributes.empty()) + { + return; + } + if (addWouldOverflow(draw.mFirstVertex, draw.mVertexCount - 1)) + { + error(ValidationCode::OutOfBounds, path, "non-indexed vertex range overflows"); + return; + } + validateVertexRange(*pipeline, draw.mResources.mVertexBuffers, draw.mFirstVertex + draw.mVertexCount - 1, path); + } + + void validateDraw(const RenderPass& pass, const DrawIndexed& draw, const std::string& path) + { + const PipelineResource* pipeline = validateDrawResources(pass, draw.mResources, path); + const BufferResource* index_buffer = find(mFrame.mBuffers, draw.mIndexBuffer.mBuffer); + const bool valid_index_type = validEnum(draw.mIndexBuffer.mType, IndexType::UInt32); + if (!valid_index_type) + { + error(ValidationCode::InvalidBinding, path, "index type is outside the contract domain"); + } + if (!index_buffer) + { + error(ValidationCode::MissingResource, path, "index buffer is not declared"); + } + else if (valid_index_type) + { + const std::uint64_t element_size = indexSize(draw.mIndexBuffer.mType); + const std::uint64_t element_count = static_cast(draw.mFirstIndex) + draw.mIndexCount; + if (draw.mIndexBuffer.mOffset % element_size != 0) + { + error(ValidationCode::InvalidBinding, path, "index buffer offset must be aligned to the index type"); + } + if (multiplyWouldOverflow(element_count, element_size) || + addWouldOverflow(draw.mIndexBuffer.mOffset, element_count * element_size) || + draw.mIndexBuffer.mOffset + element_count * element_size > index_buffer->mSize) + { + error(ValidationCode::OutOfBounds, path, "indexed draw exceeds its index buffer"); + } + } + if (!hasBufferAccess(pass, draw.mIndexBuffer.mBuffer, BufferAccessKind::IndexRead)) + { + error(ValidationCode::MissingAccess, path, "index-buffer read is not declared by the pass"); + } + if (draw.mIndexCount == 0 || draw.mInstanceCount == 0 || draw.mMinVertex > draw.mMaxVertex) + { + error(ValidationCode::InvalidBinding, path, "indexed draw counts and vertex bounds are invalid"); + return; + } + const std::int64_t min_vertex = static_cast(draw.mBaseVertex) + draw.mMinVertex; + const std::int64_t max_vertex = static_cast(draw.mBaseVertex) + draw.mMaxVertex; + if (min_vertex < 0 || max_vertex < min_vertex || max_vertex > std::numeric_limits::max()) + { + error(ValidationCode::OutOfBounds, path, "indexed draw base vertex makes its bounds invalid"); + return; + } + if (pipeline) + { + validateVertexRange(*pipeline, draw.mResources.mVertexBuffers, static_cast(max_vertex), path); + } + } + + void validateVertexRange(const PipelineResource& pipeline, const std::vector& bindings, + std::uint32_t max_vertex, const std::string& path) + { + for (const VertexBufferBinding& binding : bindings) + { + const BufferResource* buffer = find(mFrame.mBuffers, binding.mBuffer); + const auto layout = + std::find_if(pipeline.mVertexBindings.begin(), pipeline.mVertexBindings.end(), + [&binding](const VertexBindingLayout& candidate) { return candidate.mBinding == binding.mBinding; }); + if (!buffer || layout == pipeline.mVertexBindings.end()) + { + continue; + } + std::uint32_t attribute_end = 0; + for (const VertexAttribute& attribute : pipeline.mVertexAttributes) + { + if (attribute.mBinding == binding.mBinding) + { + attribute_end = std::max(attribute_end, attribute.mOffset + vertexFormatSize(attribute.mFormat)); + } + } + const std::uint64_t vertex_offset = static_cast(max_vertex) * layout->mStride; + if (addWouldOverflow(binding.mOffset, vertex_offset) || + addWouldOverflow(binding.mOffset + vertex_offset, static_cast(attribute_end)) || + binding.mOffset + vertex_offset + attribute_end > buffer->mSize) + { + error(ValidationCode::OutOfBounds, path, "draw vertex range exceeds a bound vertex buffer"); + } + } + } + + void validateReleases() + { + for (std::size_t index = 0; index < mFrame.mReleases.size(); ++index) + { + const ReleaseAfterFrame& release = mFrame.mReleases[index]; + const std::string path = "releases[" + std::to_string(index) + "]"; + const std::optional lifetime = resourceLifetime(release.mResource); + if (!resourceHandleValid(release.mResource)) + { + error(ValidationCode::InvalidHandle, path, "release handle is invalid"); + } + else if (!lifetime) + { + error(ValidationCode::MissingResource, path, "release names an undeclared resource generation"); + } + else if (*lifetime == ResourceLifetime::External) + { + error(ValidationCode::InvalidRelease, path, "externally owned resources cannot be released by the renderer"); + } + if (release.mFrame < mFrame.mFrame) + { + error(ValidationCode::InvalidRelease, path, "resource cannot retire before this frame completes"); + } + for (std::size_t prior = 0; prior < index; ++prior) + { + if (mFrame.mReleases[prior].mResource == release.mResource) + { + error(ValidationCode::InvalidRelease, path, "resource release is duplicated"); + break; + } + } + } + } + + void validateFrameLifetimes() + { + validateFrameLifetimes(mFrame.mBuffers); + validateFrameLifetimes(mFrame.mImages); + validateFrameLifetimes(mFrame.mSamplers); + validateFrameLifetimes(mFrame.mPipelines); + } + + template + void validateFrameLifetimes(const std::vector& resources) + { + for (const Descriptor& resource : resources) + { + if (resource.mLifetime != ResourceLifetime::Frame) + { + continue; + } + const ResourceHandle handle = resource.mHandle; + const auto release = + std::find_if(mFrame.mReleases.begin(), mFrame.mReleases.end(), [&handle, this](const ReleaseAfterFrame& candidate) + { return candidate.mResource == handle && candidate.mFrame == mFrame.mFrame; }); + if (release == mFrame.mReleases.end()) + { + error(ValidationCode::InvalidRelease, "releases", "frame-owned resource must retire when this frame completes"); + } + } + } + + bool resourceHandleValid(const ResourceHandle& handle) const + { + return std::visit([](const auto& typed_handle) { return static_cast(typed_handle); }, handle); + } + + std::optional resourceLifetime(const ResourceHandle& handle) const + { + return std::visit( + [this](const auto& typed_handle) -> std::optional + { + using HandleType = std::decay_t; + if constexpr (std::is_same_v) + { + if (const BufferResource* resource = find(mFrame.mBuffers, typed_handle)) + { + return resource->mLifetime; + } + } + else if constexpr (std::is_same_v) + { + if (const ImageResource* resource = find(mFrame.mImages, typed_handle)) + { + return resource->mLifetime; + } + } + else if constexpr (std::is_same_v) + { + if (const SamplerResource* resource = find(mFrame.mSamplers, typed_handle)) + { + return resource->mLifetime; + } + } + else + { + if (const PipelineResource* resource = find(mFrame.mPipelines, typed_handle)) + { + return resource->mLifetime; + } + } + return std::nullopt; + }, + handle); + } + + struct TrackedImageState + { + ImageHandle mImage; + ImageSubresource mSubresource; + ImageState mState; + }; + + void transition(ImageHandle image, ImageSubresource subresource, ImageState before, ImageState after, const std::string& path) + { + const auto found = std::find_if(mImageStates.begin(), mImageStates.end(), [image, subresource](const TrackedImageState& state) + { return state.mImage == image && state.mSubresource == subresource; }); + if (found != mImageStates.end()) + { + if (found->mState != before) + { + error(ValidationCode::StateMismatch, path, "image before-state does not match the preceding declared use"); + } + found->mState = after; + } + else + { + mImageStates.push_back({ image, subresource, after }); + } + } + + const FrameSnapshot& mFrame; + ValidationResult mResult; + std::vector mImageStates; + }; + +} // namespace + +ValidationResult validate(const FrameSnapshot& frame) +{ + return Validator(frame).run(); +} + +} // namespace LLRenderContract diff --git a/indra/llrender/llrendercontract.h b/indra/llrender/llrendercontract.h new file mode 100644 index 00000000000..41c595a1713 --- /dev/null +++ b/indra/llrender/llrendercontract.h @@ -0,0 +1,544 @@ +/** + * @file llrendercontract.h + * @brief API-neutral renderer work descriptions. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * + * This library is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU + * Lesser General Public License for more details. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERCONTRACT_H +#define LL_LLRENDERCONTRACT_H + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ + +template +struct Handle +{ + std::uint32_t mIndex = 0; + std::uint32_t mGeneration = 0; + + constexpr explicit operator bool() const noexcept { return mIndex != 0 && mGeneration != 0; } + + friend constexpr bool operator==(const Handle&, const Handle&) = default; +}; + +template +constexpr std::optional> nextHandleGeneration(Handle current) noexcept +{ + if (!current || current.mGeneration == std::numeric_limits::max()) + { + return std::nullopt; + } + return Handle{ current.mIndex, current.mGeneration + 1 }; +} + +struct BufferTag; +struct ImageTag; +struct SamplerTag; +struct PipelineTag; +struct ShaderTag; + +using BufferHandle = Handle; +using ImageHandle = Handle; +using SamplerHandle = Handle; +using PipelineHandle = Handle; +using ShaderHandle = Handle; +using ResourceHandle = std::variant; + +struct PassId +{ + std::uint32_t mValue = 0; + + constexpr explicit operator bool() const noexcept { return mValue != 0; } + friend constexpr bool operator==(const PassId&, const PassId&) = default; +}; + +struct Extent2D +{ + std::uint32_t mWidth = 0; + std::uint32_t mHeight = 0; +}; + +struct Offset2D +{ + std::uint32_t mX = 0; + std::uint32_t mY = 0; +}; + +struct ImageSubresource +{ + std::uint32_t mMipLevel = 0; + std::uint32_t mArrayLayer = 0; + + friend constexpr bool operator==(const ImageSubresource&, const ImageSubresource&) = default; +}; + +struct ImageSubresourceRange +{ + std::uint32_t mBaseMipLevel = 0; + std::uint32_t mMipLevelCount = 1; + std::uint32_t mBaseArrayLayer = 0; + std::uint32_t mArrayLayerCount = 1; + + friend constexpr bool operator==(const ImageSubresourceRange&, const ImageSubresourceRange&) = default; +}; + +struct Viewport +{ + float mX = 0.f; + float mY = 0.f; + float mWidth = 0.f; + float mHeight = 0.f; + float mMinDepth = 0.f; + float mMaxDepth = 1.f; +}; + +struct Scissor +{ + std::uint32_t mX = 0; + std::uint32_t mY = 0; + std::uint32_t mWidth = 0; + std::uint32_t mHeight = 0; +}; + +struct ClearColor +{ + float mRed = 0.f; + float mGreen = 0.f; + float mBlue = 0.f; + float mAlpha = 0.f; +}; + +struct ByteRange +{ + std::shared_ptr> mStorage; + std::size_t mOffset = 0; + std::size_t mSize = 0; +}; + +enum class ResourceLifetime +{ + External, + Persistent, + Frame +}; + +enum class PixelFormat +{ + R8Unorm, + RG8Unorm, + RGB8Unorm, + RGBA8Unorm, + RGBA8Srgb, + RGB10A2Unorm, + RGBA16Unorm, + R16Float, + RGBA16Float, + Depth24Unorm, + Depth32Float, + RGB16Float +}; + +enum class ImageState +{ + Undefined, + TransferDestination, + ShaderRead, + ColorAttachment, + DepthAttachment +}; + +enum class ImageAccessKind +{ + SampledRead, + ColorAttachmentWrite, + DepthAttachmentReadWrite +}; + +enum class BufferAccessKind +{ + VertexRead, + IndexRead +}; + +enum class LoadOp +{ + Load, + Clear, + DontCare +}; + +enum class StoreOp +{ + Store, + DontCare +}; + +enum class Filter +{ + Nearest, + Linear +}; + +enum class MipFilter +{ + Disabled, + Nearest, + Linear +}; + +enum class AddressMode +{ + Clamp, + Repeat, + Mirror +}; + +enum class PrimitiveTopology +{ + TriangleList +}; + +enum class CullMode +{ + Disabled, + Back +}; + +enum class FrontFace +{ + Clockwise, + CounterClockwise +}; + +enum class CompareOp +{ + AlwaysPass, + LessOrEqual +}; + +enum class VertexSemantic +{ + Position, + Normal, + TexCoord0, + Color, + Tangent, + TexCoord1, + TexCoord2 +}; + +enum class VertexFormat +{ + Float2, + Float3, + Float4, + UNorm8x4 +}; + +enum class IndexType +{ + UInt16, + UInt32 +}; + +enum class RowOrigin +{ + TopLeft, + BottomLeft +}; + +enum class MipGeneration +{ + Disabled, + GenerateRemaining +}; + +struct BufferResource +{ + BufferHandle mHandle; + std::uint64_t mSize = 0; + ResourceLifetime mLifetime = ResourceLifetime::Persistent; +}; + +struct ImageResource +{ + ImageHandle mHandle; + Extent2D mExtent; + std::uint32_t mMipLevels = 1; + std::uint32_t mArrayLayers = 1; + std::uint32_t mSamples = 1; + PixelFormat mFormat = PixelFormat::RGBA8Unorm; + ResourceLifetime mLifetime = ResourceLifetime::Persistent; +}; + +struct SamplerResource +{ + SamplerHandle mHandle; + Filter mMinFilter = Filter::Linear; + Filter mMagFilter = Filter::Linear; + MipFilter mMipFilter = MipFilter::Disabled; + AddressMode mAddressU = AddressMode::Clamp; + AddressMode mAddressV = AddressMode::Clamp; + float mMaxAnisotropy = 1.f; + ResourceLifetime mLifetime = ResourceLifetime::Persistent; +}; + +struct VertexAttribute +{ + VertexSemantic mSemantic = VertexSemantic::Position; + VertexFormat mFormat = VertexFormat::Float3; + std::uint32_t mBinding = 0; + std::uint32_t mOffset = 0; +}; + +struct VertexBindingLayout +{ + std::uint32_t mBinding = 0; + std::uint32_t mStride = 0; +}; + +struct ShaderProgramKey +{ + std::string mName; + std::uint64_t mVariant = 0; +}; + +struct ColorTargetState +{ + PixelFormat mFormat = PixelFormat::RGBA8Unorm; + bool mBlendEnabled = false; + std::uint8_t mWriteMask = 0xf; +}; + +struct ParameterLayout +{ + std::uint32_t mBinding = 0; + std::uint32_t mSize = 0; +}; + +struct PipelineResource +{ + PipelineHandle mHandle; + ShaderProgramKey mProgram; + PrimitiveTopology mTopology = PrimitiveTopology::TriangleList; + CullMode mCullMode = CullMode::Disabled; + FrontFace mFrontFace = FrontFace::CounterClockwise; + bool mDepthTestEnabled = false; + bool mDepthWriteEnabled = false; + CompareOp mDepthCompare = CompareOp::AlwaysPass; + std::uint32_t mSamples = 1; + std::vector mColorTargets; + std::optional mDepthFormat; + std::vector mVertexBindings; + std::vector mVertexAttributes; + std::vector mSampledImageBindings; + std::vector mParameterBindings; + ResourceLifetime mLifetime = ResourceLifetime::Persistent; +}; + +struct BufferAccess +{ + BufferHandle mBuffer; + BufferAccessKind mKind = BufferAccessKind::VertexRead; +}; + +struct ImageAccess +{ + ImageHandle mImage; + ImageSubresourceRange mRange; + ImageAccessKind mKind = ImageAccessKind::SampledRead; + ImageState mBefore = ImageState::ShaderRead; + ImageState mDuring = ImageState::ShaderRead; + ImageState mAfter = ImageState::ShaderRead; +}; + +struct ColorAttachment +{ + ImageHandle mImage; + ImageSubresource mSubresource; + LoadOp mLoad = LoadOp::DontCare; + StoreOp mStore = StoreOp::Store; + ClearColor mClear; +}; + +struct DepthAttachment +{ + ImageHandle mImage; + ImageSubresource mSubresource; + LoadOp mLoad = LoadOp::Load; + StoreOp mStore = StoreOp::Store; + float mClearDepth = 1.f; +}; + +struct VertexBufferBinding +{ + std::uint32_t mBinding = 0; + BufferHandle mBuffer; + std::uint64_t mOffset = 0; +}; + +struct IndexBufferBinding +{ + BufferHandle mBuffer; + std::uint64_t mOffset = 0; + IndexType mType = IndexType::UInt16; +}; + +struct SampledImageBinding +{ + std::uint32_t mBinding = 0; + ImageHandle mImage; + ImageSubresourceRange mRange; + SamplerHandle mSampler; +}; + +struct ParameterBinding +{ + std::uint32_t mBinding = 0; + ByteRange mBytes; +}; + +struct DrawResources +{ + PipelineHandle mPipeline; + std::vector mVertexBuffers; + std::vector mSampledImages; + std::vector mParameters; +}; + +struct Draw +{ + DrawResources mResources; + std::uint32_t mFirstVertex = 0; + std::uint32_t mVertexCount = 0; + std::uint32_t mFirstInstance = 0; + std::uint32_t mInstanceCount = 1; +}; + +struct DrawIndexed +{ + DrawResources mResources; + IndexBufferBinding mIndexBuffer; + std::uint32_t mFirstIndex = 0; + std::uint32_t mIndexCount = 0; + std::int32_t mBaseVertex = 0; + std::uint32_t mMinVertex = 0; + std::uint32_t mMaxVertex = 0; + std::uint32_t mFirstInstance = 0; + std::uint32_t mInstanceCount = 1; +}; + +using DrawCommand = std::variant; + +struct RenderPass +{ + PassId mId; + std::string mLabel; + Extent2D mExtent; + Viewport mViewport; + Scissor mScissor; + std::vector mDependencies; + std::vector mBufferAccesses; + std::vector mImageAccesses; + std::vector mColorAttachments; + std::optional mDepthAttachment; + std::vector mDraws; +}; + +struct TextureUpload +{ + ImageHandle mDestination; + std::uint64_t mRevision = 0; + ImageSubresource mSubresource; + Offset2D mOffset; + Extent2D mExtent; + Extent2D mLogicalExtent; + std::uint32_t mResidentDiscard = 0; + PixelFormat mSourceFormat = PixelFormat::RGBA8Unorm; + std::uint32_t mRowPitch = 0; + RowOrigin mRowOrigin = RowOrigin::TopLeft; + MipGeneration mMipGeneration = MipGeneration::Disabled; + ByteRange mPixels; + ImageState mBefore = ImageState::Undefined; + ImageState mDuring = ImageState::TransferDestination; + ImageState mAfter = ImageState::ShaderRead; +}; + +struct ReleaseAfterFrame +{ + ResourceHandle mResource; + std::uint64_t mFrame = 0; +}; + +struct FrameSnapshot +{ + std::uint64_t mFrame = 0; + std::vector mBuffers; + std::vector mImages; + std::vector mSamplers; + std::vector mPipelines; + std::vector mUploads; + std::vector mPasses; + std::vector mReleases; +}; + +enum class ValidationCode +{ + InvalidFrame, + InvalidHandle, + DuplicateResource, + MissingResource, + InvalidResource, + InvalidDependency, + InvalidPass, + MissingAccess, + InvalidState, + StateMismatch, + InvalidBinding, + OutOfBounds, + InvalidUpload, + InvalidRelease +}; + +struct ValidationError +{ + ValidationCode mCode = ValidationCode::InvalidFrame; + std::string mPath; + std::string mMessage; +}; + +struct ValidationResult +{ + std::vector mErrors; + + explicit operator bool() const noexcept { return mErrors.empty(); } +}; + +ValidationResult validate(const FrameSnapshot& frame); + +} // namespace LLRenderContract + +#endif // LL_LLRENDERCONTRACT_H diff --git a/indra/llrender/llrenderglmaterial.cpp b/indra/llrender/llrenderglmaterial.cpp new file mode 100644 index 00000000000..62af7f2b785 --- /dev/null +++ b/indra/llrender/llrenderglmaterial.cpp @@ -0,0 +1,774 @@ +/** + * @file llrenderglmaterial.cpp + * @brief OpenGL replay of the canonical Stage 12 material packet. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrenderglmaterial.h" + +#include "llgl.h" +#include "llglslshader.h" +#include "llrender.h" +#include "llrendertarget.h" +#include "llshadermgr.h" +#include "llvertexbuffer.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace LLRenderGLMaterial +{ +namespace +{ + + constexpr U32 MATERIAL_VERTEX_MASK = LLVertexBuffer::MAP_VERTEX | LLVertexBuffer::MAP_NORMAL | LLVertexBuffer::MAP_TEXCOORD0 | + LLVertexBuffer::MAP_TEXCOORD1 | LLVertexBuffer::MAP_TEXCOORD2 | LLVertexBuffer::MAP_COLOR | + LLVertexBuffer::MAP_TANGENT; + + struct Prepared + { + LLRenderContract::MaterialInputs mInputs; + LLVertexBuffer* mVertexBuffer = nullptr; + std::array mSampledImages{}; + LLRenderTarget* mTarget = nullptr; + const Sampler* mSampler = nullptr; + LLGLSLShader* mShader = nullptr; + }; + + bool sameExtent(LLRenderContract::Extent2D left, LLRenderContract::Extent2D right) + { + return left.mWidth == right.mWidth && left.mHeight == right.mHeight; + } + + bool noGlError() + { + return glGetError() == GL_NO_ERROR; + } + + bool targetColorFormat(const LLRenderTarget& target, U32 attachment, LLRenderContract::PixelFormat format) + { + const U32 gl_format = target.getColorFormat(attachment); + if (format == LLRenderContract::PixelFormat::RGBA8Unorm) + { + return gl_format == GL_RGBA || gl_format == GL_RGBA8; + } + return format == LLRenderContract::PixelFormat::RGBA16Unorm && gl_format == GL_RGBA16; + } + + GLint internalFormat(LLRenderContract::PixelFormat format) + { + switch (format) + { + case LLRenderContract::PixelFormat::RGBA8Unorm: + return GL_RGBA8; + case LLRenderContract::PixelFormat::RGBA16Unorm: + return GL_RGBA16; + case LLRenderContract::PixelFormat::Depth24Unorm: + return GL_DEPTH_COMPONENT24; + default: + return 0; + } + } + + bool compatibleInternalFormat(LLRenderContract::PixelFormat format, GLint actual) + { + if (format == LLRenderContract::PixelFormat::Depth24Unorm) + { + // Apple's OpenGL implementation stores an explicit DEPTH_COMPONENT24 + // request as normalized DEPTH_COMPONENT32. Readback is still + // canonicalized to 24 bits by the shared diagnostic. + return actual == GL_DEPTH_COMPONENT24 || actual == GL_DEPTH_COMPONENT32; + } + return actual == internalFormat(format); + } + + // Texture inspection uses one temporary binding and restores the raw binding and + // active unit. It never changes texture parameters or framebuffer attachments. + bool liveTexture(GLuint texture, LLRenderContract::PixelFormat format, LLRenderContract::Extent2D extent, std::uint32_t mip_levels) + { + if (texture == 0 || mip_levels == 0 || !glIsTexture(texture)) + { + return false; + } + + GLint previous_active = GL_TEXTURE0; + glGetIntegerv(GL_ACTIVE_TEXTURE, &previous_active); + glActiveTexture(GL_TEXTURE0); + + GLint previous_binding = 0; + glGetIntegerv(GL_TEXTURE_BINDING_2D, &previous_binding); + glBindTexture(GL_TEXTURE_2D, texture); + + bool valid = true; + for (std::uint32_t level = 0; level < mip_levels; ++level) + { + GLint width = 0; + GLint height = 0; + GLint level_format = 0; + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_WIDTH, &width); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_HEIGHT, &height); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_INTERNAL_FORMAT, &level_format); + + const GLint expected_width = static_cast(std::max(1, extent.mWidth >> level)); + const GLint expected_height = static_cast(std::max(1, extent.mHeight >> level)); + valid = valid && width == expected_width && height == expected_height && compatibleInternalFormat(format, level_format); + } + + GLint extra_width = 0; + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(mip_levels), GL_TEXTURE_WIDTH, &extra_width); + valid = valid && extra_width == 0; + + glBindTexture(GL_TEXTURE_2D, static_cast(previous_binding)); + glActiveTexture(static_cast(previous_active)); + return valid && noGlError(); + } + + struct ActiveVariable + { + GLint mLocation = -1; + GLint mSize = 0; + GLenum mType = 0; + }; + + using ActiveVariables = std::map; + + std::optional activeVariables(GLuint program, GLenum count_name, GLenum max_length_name, bool attributes) + { + GLint count = 0; + GLint max_length = 0; + glGetProgramiv(program, count_name, &count); + glGetProgramiv(program, max_length_name, &max_length); + if (count < 0 || max_length <= 0) + { + return std::nullopt; + } + + std::vector name(static_cast(max_length)); + ActiveVariables result; + for (GLint index = 0; index < count; ++index) + { + GLsizei length = 0; + GLint size = 0; + GLenum type = 0; + if (attributes) + { + glGetActiveAttrib(program, static_cast(index), max_length, &length, &size, &type, name.data()); + } + else + { + glGetActiveUniform(program, static_cast(index), max_length, &length, &size, &type, name.data()); + } + if (length <= 0) + { + return std::nullopt; + } + + std::string variable(name.data(), static_cast(length)); + if (!attributes && variable.ends_with("[0]")) + { + variable.resize(variable.size() - 3); + } + const GLint location = + attributes ? glGetAttribLocation(program, variable.c_str()) : glGetUniformLocation(program, variable.c_str()); + if ((attributes && location < 0) || !result.emplace(std::move(variable), ActiveVariable{ location, size, type }).second) + { + return std::nullopt; + } + } + if (!noGlError()) + { + return std::nullopt; + } + return result; + } + + bool exactVariables(const ActiveVariables& actual, const ActiveVariables& expected) + { + if (actual.size() != expected.size()) + { + return false; + } + for (const auto& [name, expected_variable] : expected) + { + const auto found = actual.find(name); + if (found == actual.end() || found->second.mLocation != expected_variable.mLocation || + found->second.mSize != expected_variable.mSize || found->second.mType != expected_variable.mType) + { + return false; + } + } + return true; + } + + bool matchesShader(LLGLSLShader& shader) + { + if (!shader.isComplete() || shader.mName != "Material Shader 12" || shader.mShaderLevel != 3 || shader.mUsingBinaryProgram || + shader.mAttributeMask != MATERIAL_VERTEX_MASK || shader.mFeatures.hasObjectSkinning || !shader.mFeatures.hasReflectionProbes || + shader.mProgramObject == 0 || !glIsProgram(shader.mProgramObject)) + { + return false; + } + + const LLGLSLShader::defines_map_t expected_defines{ { "DIFFUSE_ALPHA_MODE", "0" }, + { "HAS_NORMAL_MAP", "1" }, + { "HAS_SPECULAR_MAP", "1" } }; + const std::vector> expected_files{ { "deferred/materialV.glsl", GL_VERTEX_SHADER }, + { "deferred/materialF.glsl", GL_FRAGMENT_SHADER } }; + if (shader.mDefines != expected_defines || shader.mShaderFiles != expected_files) + { + return false; + } + + GLint linked = GL_FALSE; + glGetProgramiv(shader.mProgramObject, GL_LINK_STATUS, &linked); + if (linked != GL_TRUE) + { + return false; + } + + const ActiveVariables expected_attributes{ { "position", { LLVertexBuffer::TYPE_VERTEX, 1, GL_FLOAT_VEC3 } }, + { "normal", { LLVertexBuffer::TYPE_NORMAL, 1, GL_FLOAT_VEC3 } }, + { "texcoord0", { LLVertexBuffer::TYPE_TEXCOORD0, 1, GL_FLOAT_VEC2 } }, + { "texcoord1", { LLVertexBuffer::TYPE_TEXCOORD1, 1, GL_FLOAT_VEC2 } }, + { "texcoord2", { LLVertexBuffer::TYPE_TEXCOORD2, 1, GL_FLOAT_VEC2 } }, + { "diffuse_color", { LLVertexBuffer::TYPE_COLOR, 1, GL_FLOAT_VEC4 } }, + { "tangent", { LLVertexBuffer::TYPE_TANGENT, 1, GL_FLOAT_VEC4 } } }; + const auto attributes = activeVariables(shader.mProgramObject, GL_ACTIVE_ATTRIBUTES, GL_ACTIVE_ATTRIBUTE_MAX_LENGTH, true); + if (!attributes || !exactVariables(*attributes, expected_attributes)) + { + return false; + } + + const ActiveVariables expected_uniform_types{ { "modelview_matrix", { 0, 1, GL_FLOAT_MAT4 } }, + { "modelview_projection_matrix", { 0, 1, GL_FLOAT_MAT4 } }, + { "normal_matrix", { 0, 1, GL_FLOAT_MAT3 } }, + { "texture_matrix0", { 0, 1, GL_FLOAT_MAT4 } }, + { "diffuseMap", { 0, 1, GL_SAMPLER_2D } }, + { "bumpMap", { 0, 1, GL_SAMPLER_2D } }, + { "specularMap", { 0, 1, GL_SAMPLER_2D } }, + { "emissive_brightness", { 0, 1, GL_FLOAT } }, + { "env_intensity", { 0, 1, GL_FLOAT } }, + { "specular_color", { 0, 1, GL_FLOAT_VEC4 } }, + { "mirror_flag", { 0, 1, GL_FLOAT } }, + { "clipPlane", { 0, 1, GL_FLOAT_VEC4 } }, + { "refBox", { -1, 256, GL_FLOAT_MAT4 } }, + { "heroBox", { -1, 1, GL_FLOAT_MAT4 } }, + { "refSphere", { -1, 256, GL_FLOAT_VEC4 } }, + { "refParams", { -1, 256, GL_FLOAT_VEC4 } }, + { "heroSphere", { -1, 1, GL_FLOAT_VEC4 } }, + { "refIndex", { -1, 256, GL_INT_VEC4 } }, + { "refNeighbor", { -1, 1024, GL_INT_VEC4 } }, + { "refBucket", { -1, 256, GL_INT_VEC4 } }, + { "refmapCount", { -1, 1, GL_INT } }, + { "heroShape", { -1, 1, GL_INT } }, + { "heroMipCount", { -1, 1, GL_INT } }, + { "heroProbeCount", { -1, 1, GL_INT } } }; + const auto uniforms = activeVariables(shader.mProgramObject, GL_ACTIVE_UNIFORMS, GL_ACTIVE_UNIFORM_MAX_LENGTH, false); + if (!uniforms || uniforms->size() != expected_uniform_types.size()) + { + return false; + } + for (const auto& [name, expected] : expected_uniform_types) + { + const auto found = uniforms->find(name); + const bool valid_location = + found != uniforms->end() && (expected.mLocation < 0 ? found->second.mLocation == -1 : found->second.mLocation >= 0); + if (!valid_location || found->second.mSize != expected.mSize || found->second.mType != expected.mType) + { + return false; + } + } + + GLint active_blocks = 0; + glGetProgramiv(shader.mProgramObject, GL_ACTIVE_UNIFORM_BLOCKS, &active_blocks); + const GLuint reflection_block = glGetUniformBlockIndex(shader.mProgramObject, "ReflectionProbes"); + GLint block_binding = -1; + GLint block_size = 0; + GLint block_uniforms = 0; + if (reflection_block != GL_INVALID_INDEX) + { + glGetActiveUniformBlockiv(shader.mProgramObject, reflection_block, GL_UNIFORM_BLOCK_BINDING, &block_binding); + glGetActiveUniformBlockiv(shader.mProgramObject, reflection_block, GL_UNIFORM_BLOCK_DATA_SIZE, &block_size); + glGetActiveUniformBlockiv(shader.mProgramObject, reflection_block, GL_UNIFORM_BLOCK_ACTIVE_UNIFORMS, &block_uniforms); + } + if (active_blocks != 1 || reflection_block == GL_INVALID_INDEX || block_binding != LLGLSLShader::UB_REFLECTION_PROBES || + block_size != 49248 || block_uniforms != 12) + { + return false; + } + + const GLint output0 = glGetFragDataLocation(shader.mProgramObject, "frag_data[0]"); + const GLint output1 = glGetFragDataLocation(shader.mProgramObject, "frag_data[1]"); + const GLint output2 = glGetFragDataLocation(shader.mProgramObject, "frag_data[2]"); + const GLint output3 = glGetFragDataLocation(shader.mProgramObject, "frag_data[3]"); + if (output0 != 0 || output1 != 1 || output2 != 2 || output3 != 3) + { + return false; + } + + const S32 diffuse_channel = shader.getTextureChannel(LLShaderMgr::DIFFUSE_MAP); + const S32 bump_channel = shader.getTextureChannel(LLShaderMgr::BUMP_MAP); + const S32 specular_channel = shader.getTextureChannel(LLShaderMgr::SPECULAR_MAP); + if (shader.mActiveTextureChannels != 3 || diffuse_channel != LLRender::DIFFUSE_MAP || bump_channel != LLRender::NORMAL_MAP || + specular_channel != LLRender::SPECULAR_MAP) + { + return false; + } + + constexpr std::array, 12> uniform_mappings{ + std::pair{ LLShaderMgr::MODELVIEW_MATRIX, "modelview_matrix" }, + { LLShaderMgr::MODELVIEW_PROJECTION_MATRIX, "modelview_projection_matrix" }, + { LLShaderMgr::NORMAL_MATRIX, "normal_matrix" }, + { LLShaderMgr::TEXTURE_MATRIX0, "texture_matrix0" }, + { LLShaderMgr::DIFFUSE_MAP, "diffuseMap" }, + { LLShaderMgr::BUMP_MAP, "bumpMap" }, + { LLShaderMgr::SPECULAR_MAP, "specularMap" }, + { LLShaderMgr::EMISSIVE_BRIGHTNESS, "emissive_brightness" }, + { LLShaderMgr::ENVIRONMENT_INTENSITY, "env_intensity" }, + { LLShaderMgr::SPECULAR_COLOR, "specular_color" }, + { LLShaderMgr::MIRROR_FLAG, "mirror_flag" }, + { LLShaderMgr::CLIP_PLANE, "clipPlane" } + }; + for (const auto& [uniform, name] : uniform_mappings) + { + if (shader.getUniformLocation(uniform) != glGetUniformLocation(shader.mProgramObject, name)) + { + return false; + } + } + return noGlError(); + } + + bool matchesVertexMetadata(const LLVertexBuffer& buffer) + { + return buffer.getNumVerts() == 4 && buffer.getNumIndices() == 6 && buffer.getTypeMask() == MATERIAL_VERTEX_MASK && + buffer.getSize() == LLRenderContract::MATERIAL_VERTEX_BUFFER_SIZE && + buffer.getIndicesSize() == LLRenderContract::MATERIAL_INDEX_BUFFER_SIZE && + buffer.getOffset(LLVertexBuffer::TYPE_VERTEX) == LLRenderContract::MATERIAL_POSITION_OFFSET && + buffer.getOffset(LLVertexBuffer::TYPE_NORMAL) == LLRenderContract::MATERIAL_NORMAL_OFFSET && + buffer.getOffset(LLVertexBuffer::TYPE_TEXCOORD0) == LLRenderContract::MATERIAL_TEXCOORD0_OFFSET && + buffer.getOffset(LLVertexBuffer::TYPE_TEXCOORD1) == LLRenderContract::MATERIAL_TEXCOORD1_OFFSET && + buffer.getOffset(LLVertexBuffer::TYPE_TEXCOORD2) == LLRenderContract::MATERIAL_TEXCOORD2_OFFSET && + buffer.getOffset(LLVertexBuffer::TYPE_COLOR) == LLRenderContract::MATERIAL_COLOR_OFFSET && + buffer.getOffset(LLVertexBuffer::TYPE_TANGENT) == LLRenderContract::MATERIAL_TANGENT_OFFSET; + } + + struct VertexArrayExpectation + { + GLuint mLocation; + GLint mComponents; + GLenum mType; + GLboolean mNormalized; + GLsizei mStride; + std::uintptr_t mOffset; + }; + + bool liveVertexBuffers(LLVertexBuffer& buffer) + { + buffer.setBuffer(); + + GLint vertex_name = 0; + GLint index_name = 0; + GLint vertex_size = 0; + GLint index_size = 0; + glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &vertex_name); + glGetIntegerv(GL_ELEMENT_ARRAY_BUFFER_BINDING, &index_name); + if (vertex_name == 0 || index_name == 0 || vertex_name == index_name || + vertex_name != static_cast(LLVertexBuffer::sGLRenderBuffer) || + index_name != static_cast(LLVertexBuffer::sGLRenderIndices) || !glIsBuffer(static_cast(vertex_name)) || + !glIsBuffer(static_cast(index_name))) + { + return false; + } + glGetBufferParameteriv(GL_ARRAY_BUFFER, GL_BUFFER_SIZE, &vertex_size); + glGetBufferParameteriv(GL_ELEMENT_ARRAY_BUFFER, GL_BUFFER_SIZE, &index_size); + if (vertex_size < static_cast(LLRenderContract::MATERIAL_VERTEX_BUFFER_SIZE) || + index_size < static_cast(LLRenderContract::MATERIAL_INDEX_BUFFER_SIZE)) + { + return false; + } + + constexpr std::array expectations{ + VertexArrayExpectation{ LLVertexBuffer::TYPE_VERTEX, 3, GL_FLOAT, GL_FALSE, 16, LLRenderContract::MATERIAL_POSITION_OFFSET }, + { LLVertexBuffer::TYPE_NORMAL, 3, GL_FLOAT, GL_FALSE, 16, LLRenderContract::MATERIAL_NORMAL_OFFSET }, + { LLVertexBuffer::TYPE_TEXCOORD0, 2, GL_FLOAT, GL_FALSE, 8, LLRenderContract::MATERIAL_TEXCOORD0_OFFSET }, + { LLVertexBuffer::TYPE_TEXCOORD1, 2, GL_FLOAT, GL_FALSE, 8, LLRenderContract::MATERIAL_TEXCOORD1_OFFSET }, + { LLVertexBuffer::TYPE_TEXCOORD2, 2, GL_FLOAT, GL_FALSE, 8, LLRenderContract::MATERIAL_TEXCOORD2_OFFSET }, + { LLVertexBuffer::TYPE_COLOR, 4, GL_UNSIGNED_BYTE, GL_TRUE, 4, LLRenderContract::MATERIAL_COLOR_OFFSET }, + { LLVertexBuffer::TYPE_TANGENT, 4, GL_FLOAT, GL_FALSE, 16, LLRenderContract::MATERIAL_TANGENT_OFFSET } + }; + for (const VertexArrayExpectation& expected : expectations) + { + glVertexAttribDivisor(expected.mLocation, 0); + GLint enabled = GL_FALSE; + GLint components = 0; + GLint type = 0; + GLint normalized = GL_FALSE; + GLint integer = GL_FALSE; + GLint stride = 0; + GLint divisor = 0; + GLint bound_buffer = 0; + void* pointer = nullptr; + glGetVertexAttribiv(expected.mLocation, GL_VERTEX_ATTRIB_ARRAY_ENABLED, &enabled); + glGetVertexAttribiv(expected.mLocation, GL_VERTEX_ATTRIB_ARRAY_SIZE, &components); + glGetVertexAttribiv(expected.mLocation, GL_VERTEX_ATTRIB_ARRAY_TYPE, &type); + glGetVertexAttribiv(expected.mLocation, GL_VERTEX_ATTRIB_ARRAY_NORMALIZED, &normalized); + glGetVertexAttribiv(expected.mLocation, GL_VERTEX_ATTRIB_ARRAY_INTEGER, &integer); + glGetVertexAttribiv(expected.mLocation, GL_VERTEX_ATTRIB_ARRAY_STRIDE, &stride); + glGetVertexAttribiv(expected.mLocation, GL_VERTEX_ATTRIB_ARRAY_DIVISOR, &divisor); + glGetVertexAttribiv(expected.mLocation, GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING, &bound_buffer); + glGetVertexAttribPointerv(expected.mLocation, GL_VERTEX_ATTRIB_ARRAY_POINTER, &pointer); + if (enabled != GL_TRUE || components != expected.mComponents || type != static_cast(expected.mType) || + normalized != expected.mNormalized || integer != GL_FALSE || stride != expected.mStride || bound_buffer != vertex_name || + divisor != 0 || reinterpret_cast(pointer) != expected.mOffset) + { + return false; + } + } + + std::array indices{}; + glGetBufferSubData(GL_ELEMENT_ARRAY_BUFFER, 0, sizeof(indices), indices.data()); + return noGlError() && indices == LLRenderContract::MATERIAL_INDICES; + } + + std::optional prepare(const LLRenderContract::FrameSnapshot& frame, const Registry& registry) + { + auto inputs = LLRenderContract::decodeMaterialFrame(frame); + if (!inputs || !noGlError()) + { + return std::nullopt; + } + + Prepared result; + result.mInputs = *inputs; + result.mVertexBuffer = registry.resolveVertexBuffer(inputs->mHandles.mVertexBuffer, inputs->mHandles.mIndexBuffer); + result.mSampledImages = { registry.resolveSampledImage(inputs->mHandles.mDiffuse), + registry.resolveSampledImage(inputs->mHandles.mNormal), + registry.resolveSampledImage(inputs->mHandles.mSpecular) }; + result.mSampler = registry.resolve(inputs->mHandles.mSampler); + result.mShader = registry.resolve(inputs->mHandles.mPipeline, frame.mPipelines.front().mProgram); + + const TargetImage gbuffer0 = registry.resolveTargetImage(inputs->mHandles.mGBuffer0); + const TargetImage gbuffer1 = registry.resolveTargetImage(inputs->mHandles.mGBuffer1); + const TargetImage gbuffer2 = registry.resolveTargetImage(inputs->mHandles.mGBuffer2); + const TargetImage depth = registry.resolveTargetImage(inputs->mHandles.mDepth); + result.mTarget = gbuffer0.mTarget; + + constexpr Sampler expected_sampler; + const LLRenderContract::Extent2D texture_extent{ LLRenderContract::MATERIAL_TEXTURE_WIDTH, + LLRenderContract::MATERIAL_TEXTURE_HEIGHT }; + if (!result.mVertexBuffer || !result.mSampledImages[0] || !result.mSampledImages[1] || !result.mSampledImages[2] || + !result.mSampler || *result.mSampler != expected_sampler || !result.mShader || !result.mTarget || + gbuffer1.mTarget != result.mTarget || gbuffer2.mTarget != result.mTarget || depth.mTarget != result.mTarget || + gbuffer0.mAspect != TargetAspect::Color || gbuffer0.mAttachment != 0 || gbuffer1.mAspect != TargetAspect::Color || + gbuffer1.mAttachment != 1 || gbuffer2.mAspect != TargetAspect::Color || gbuffer2.mAttachment != 2 || + depth.mAspect != TargetAspect::Depth || !matchesVertexMetadata(*result.mVertexBuffer) || !gGLManager.mHasAnisotropic || + gGLManager.mMaxAnisotropy < 8.f) + { + return std::nullopt; + } + + if (!result.mTarget->isComplete() || result.mTarget->isBoundInStack() || + LLRenderTarget::getCurrentBoundTarget() == result.mTarget || result.mTarget->getUsage() != LLTexUnit::TT_TEXTURE || + result.mTarget->getWidth() != LLRenderContract::MATERIAL_FRAME_WIDTH || + result.mTarget->getHeight() != LLRenderContract::MATERIAL_FRAME_HEIGHT || result.mTarget->getNumTextures() != 3 || + !targetColorFormat(*result.mTarget, 0, LLRenderContract::PixelFormat::RGBA8Unorm) || + !targetColorFormat(*result.mTarget, 1, LLRenderContract::PixelFormat::RGBA8Unorm) || + !targetColorFormat(*result.mTarget, 2, LLRenderContract::PixelFormat::RGBA16Unorm)) + { + return std::nullopt; + } + + std::array texture_names{ result.mSampledImages[0]->mTexture, result.mSampledImages[1]->mTexture, + result.mSampledImages[2]->mTexture, result.mTarget->getTexture(0), + result.mTarget->getTexture(1), result.mTarget->getTexture(2), + result.mTarget->getDepth() }; + std::array sorted_names = texture_names; + std::sort(sorted_names.begin(), sorted_names.end()); + if (sorted_names.front() == 0 || std::adjacent_find(sorted_names.begin(), sorted_names.end()) != sorted_names.end()) + { + return std::nullopt; + } + + for (const SampledImage* image : result.mSampledImages) + { + if (image->mFormat != LLRenderContract::PixelFormat::RGBA8Unorm || !sameExtent(image->mExtent, texture_extent) || + image->mMipLevels != LLRenderContract::MATERIAL_TEXTURE_MIP_LEVELS || + !liveTexture(image->mTexture, image->mFormat, image->mExtent, image->mMipLevels)) + { + return std::nullopt; + } + } + + const LLRenderContract::Extent2D frame_extent{ LLRenderContract::MATERIAL_FRAME_WIDTH, LLRenderContract::MATERIAL_FRAME_HEIGHT }; + if (!liveTexture(texture_names[3], LLRenderContract::PixelFormat::RGBA8Unorm, frame_extent, 1)) + { + return std::nullopt; + } + if (!liveTexture(texture_names[4], LLRenderContract::PixelFormat::RGBA8Unorm, frame_extent, 1)) + { + return std::nullopt; + } + if (!liveTexture(texture_names[5], LLRenderContract::PixelFormat::RGBA16Unorm, frame_extent, 1)) + { + return std::nullopt; + } + if (!liveTexture(texture_names[6], LLRenderContract::PixelFormat::Depth24Unorm, frame_extent, 1)) + { + return std::nullopt; + } + if (!matchesShader(*result.mShader)) + { + return std::nullopt; + } + return result; + } + + bool bindAndValidateVertexInput(Prepared& prepared) + { + const bool uniforms_dirty = prepared.mShader->mUniformsDirty; + prepared.mShader->mUniformsDirty = false; + prepared.mShader->bind(); + prepared.mShader->mUniformsDirty = uniforms_dirty; + + GLint current_program = 0; + glGetIntegerv(GL_CURRENT_PROGRAM, ¤t_program); + if (current_program != static_cast(prepared.mShader->mProgramObject) || + LLGLSLShader::sCurBoundShader != prepared.mShader->mProgramObject || LLGLSLShader::sCurBoundShaderPtr != prepared.mShader || + !noGlError() || !liveVertexBuffers(*prepared.mVertexBuffer)) + { + LLGLSLShader::unbind(); + return false; + } + return true; + } + + class DrawStateRestore + { + public: + DrawStateRestore() + { + for (std::size_t i = 0; i < mCapabilities.size(); ++i) + { + mEnabled[i] = glIsEnabled(mCapabilities[i]); + } + glGetIntegerv(GL_VIEWPORT, mViewport.data()); + glGetIntegerv(GL_SCISSOR_BOX, mScissor.data()); + glGetBooleanv(GL_COLOR_WRITEMASK, mColorMask.data()); + glGetBooleanv(GL_DEPTH_WRITEMASK, &mDepthMask); + glGetIntegerv(GL_DEPTH_FUNC, &mDepthFunction); + glGetDoublev(GL_DEPTH_RANGE, mDepthRange.data()); + glGetIntegerv(GL_CULL_FACE_MODE, &mCullFace); + glGetIntegerv(GL_FRONT_FACE, &mFrontFace); + glGetIntegerv(GL_POLYGON_MODE, mPolygonMode.data()); + glGetIntegerv(GL_PROVOKING_VERTEX, &mProvokingVertex); + glGetIntegerv(GL_BLEND_SRC_RGB, &mBlendSourceRgb); + glGetIntegerv(GL_BLEND_DST_RGB, &mBlendDestinationRgb); + glGetIntegerv(GL_BLEND_SRC_ALPHA, &mBlendSourceAlpha); + glGetIntegerv(GL_BLEND_DST_ALPHA, &mBlendDestinationAlpha); + glGetIntegerv(GL_BLEND_EQUATION_RGB, &mBlendEquationRgb); + glGetIntegerv(GL_BLEND_EQUATION_ALPHA, &mBlendEquationAlpha); + } + + ~DrawStateRestore() + { + for (std::size_t i = 0; i < mCapabilities.size(); ++i) + { + mEnabled[i] ? glEnable(mCapabilities[i]) : glDisable(mCapabilities[i]); + } + glViewport(mViewport[0], mViewport[1], mViewport[2], mViewport[3]); + glScissor(mScissor[0], mScissor[1], mScissor[2], mScissor[3]); + glColorMask(mColorMask[0], mColorMask[1], mColorMask[2], mColorMask[3]); + glDepthMask(mDepthMask); + glDepthFunc(static_cast(mDepthFunction)); + glDepthRange(mDepthRange[0], mDepthRange[1]); + glCullFace(static_cast(mCullFace)); + glFrontFace(static_cast(mFrontFace)); + glPolygonMode(GL_FRONT_AND_BACK, static_cast(mPolygonMode[0])); + glProvokingVertex(static_cast(mProvokingVertex)); + glBlendFuncSeparate(static_cast(mBlendSourceRgb), static_cast(mBlendDestinationRgb), + static_cast(mBlendSourceAlpha), static_cast(mBlendDestinationAlpha)); + glBlendEquationSeparate(static_cast(mBlendEquationRgb), static_cast(mBlendEquationAlpha)); + } + + void applyMaterialState() + { + // The single-sample fixture disables dithering and multisampling so the + // stored attachment bytes do not depend on ambient driver state. + constexpr std::array enabled{ GL_FALSE, GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE, + GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE }; + for (std::size_t i = 0; i < mCapabilities.size(); ++i) + { + enabled[i] ? glEnable(mCapabilities[i]) : glDisable(mCapabilities[i]); + } + glViewport(0, 0, LLRenderContract::MATERIAL_FRAME_WIDTH, LLRenderContract::MATERIAL_FRAME_HEIGHT); + glScissor(0, 0, LLRenderContract::MATERIAL_FRAME_WIDTH, LLRenderContract::MATERIAL_FRAME_HEIGHT); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glDepthMask(GL_TRUE); + glDepthFunc(GL_LEQUAL); + glDepthRange(0.0, 1.0); + glCullFace(GL_BACK); + glFrontFace(GL_CCW); + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + glProvokingVertex(GL_LAST_VERTEX_CONVENTION); + glBlendFuncSeparate(GL_ONE, GL_ZERO, GL_ONE, GL_ZERO); + glBlendEquationSeparate(GL_FUNC_ADD, GL_FUNC_ADD); + } + + private: + const std::array mCapabilities{ GL_BLEND, + GL_CULL_FACE, + GL_DEPTH_TEST, + GL_SCISSOR_TEST, + GL_STENCIL_TEST, + GL_POLYGON_OFFSET_FILL, + GL_SAMPLE_ALPHA_TO_COVERAGE, + GL_SAMPLE_COVERAGE, + GL_FRAMEBUFFER_SRGB, + GL_COLOR_LOGIC_OP, + GL_RASTERIZER_DISCARD, + GL_PRIMITIVE_RESTART, + GL_DEPTH_CLAMP, + GL_DITHER, + GL_MULTISAMPLE }; + std::array mEnabled{}; + std::array mViewport{}; + std::array mScissor{}; + std::array mColorMask{}; + GLboolean mDepthMask = GL_TRUE; + GLint mDepthFunction = GL_LESS; + std::array mDepthRange{}; + GLint mCullFace = GL_BACK; + GLint mFrontFace = GL_CCW; + std::array mPolygonMode{}; + GLint mProvokingVertex = GL_LAST_VERTEX_CONVENTION; + GLint mBlendSourceRgb = GL_ONE; + GLint mBlendDestinationRgb = GL_ZERO; + GLint mBlendSourceAlpha = GL_ONE; + GLint mBlendDestinationAlpha = GL_ZERO; + GLint mBlendEquationRgb = GL_FUNC_ADD; + GLint mBlendEquationAlpha = GL_FUNC_ADD; + }; + + void bindSampledImage(U32 unit, const SampledImage& image, const Sampler& sampler) + { + glBindSampler(unit, 0); + LLTexUnit* texture_unit = gGL.getTexUnit(unit); + texture_unit->bindManual(LLTexUnit::TT_TEXTURE, image.mTexture, true); + texture_unit->activate(); + glActiveTexture(GL_TEXTURE0 + unit); + glBindTexture(GL_TEXTURE_2D, image.mTexture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, static_cast(image.mMipLevels - 1)); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_NONE); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_LOD, -1000.f); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_LOD, 1000.f); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_LOD_BIAS, 0.f); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, GL_RED); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_G, GL_GREEN); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, GL_BLUE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, GL_ALPHA); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, sampler.mMaxAnisotropy); + } + + void eraseCachedUniform(LLGLSLShader& shader, U32 uniform) + { + const GLint location = shader.getUniformLocation(uniform); + if (location >= 0) + { + shader.mValue.erase(location); + } + } + + void applyUniforms(LLGLSLShader& shader, const LLRenderContract::MaterialParameters& parameters) + { + // Align the viewer matrix hashes first; the explicit packet values below are + // then left intact by LLVertexBuffer::drawRange's mandatory matrix sync. + gGL.syncMatrices(); + shader.uniformMatrix4fv(LLShaderMgr::MODELVIEW_MATRIX, 1, GL_FALSE, parameters.mModelviewMatrix.data()); + shader.uniformMatrix4fv(LLShaderMgr::MODELVIEW_PROJECTION_MATRIX, 1, GL_FALSE, parameters.mModelviewProjectionMatrix.data()); + shader.uniformMatrix3fv(LLShaderMgr::NORMAL_MATRIX, 1, GL_FALSE, parameters.mNormalMatrix.data()); + shader.uniformMatrix4fv(LLShaderMgr::TEXTURE_MATRIX0, 1, GL_FALSE, parameters.mTextureMatrix0.data()); + + constexpr std::array cached_uniforms{ LLShaderMgr::DIFFUSE_MAP, LLShaderMgr::BUMP_MAP, + LLShaderMgr::SPECULAR_MAP, LLShaderMgr::EMISSIVE_BRIGHTNESS, + LLShaderMgr::ENVIRONMENT_INTENSITY, LLShaderMgr::SPECULAR_COLOR, + LLShaderMgr::MIRROR_FLAG, LLShaderMgr::CLIP_PLANE }; + for (U32 uniform : cached_uniforms) + { + eraseCachedUniform(shader, uniform); + } + + shader.uniform1i(LLShaderMgr::DIFFUSE_MAP, LLRender::DIFFUSE_MAP); + shader.uniform1i(LLShaderMgr::BUMP_MAP, LLRender::NORMAL_MAP); + shader.uniform1i(LLShaderMgr::SPECULAR_MAP, LLRender::SPECULAR_MAP); + shader.uniform1f(LLShaderMgr::EMISSIVE_BRIGHTNESS, parameters.mEmissiveBrightness); + shader.uniform1f(LLShaderMgr::ENVIRONMENT_INTENSITY, parameters.mEnvironmentIntensity); + shader.uniform4fv(LLShaderMgr::SPECULAR_COLOR, 1, parameters.mSpecularColor.data()); + shader.uniform1f(LLShaderMgr::MIRROR_FLAG, parameters.mMirror); + shader.uniform4fv(LLShaderMgr::CLIP_PLANE, 1, parameters.mClipPlane.data()); + } + +} // namespace + +bool execute(const LLRenderContract::FrameSnapshot& frame, const Registry& registry) +{ + auto prepared = prepare(frame, registry); + if (!prepared || !bindAndValidateVertexInput(*prepared)) + { + return false; + } + + bool success = true; + prepared->mTarget->bindTarget(); + { + DrawStateRestore state; + state.applyMaterialState(); + + constexpr GLfloat clear[4] = { 0.f, 0.f, 0.f, 0.f }; + glClearBufferfv(GL_COLOR, 0, clear); + glClearBufferfv(GL_COLOR, 1, clear); + glClearBufferfv(GL_COLOR, 2, clear); + + applyUniforms(*prepared->mShader, prepared->mInputs.mParameters); + bindSampledImage(LLRender::DIFFUSE_MAP, *prepared->mSampledImages[0], *prepared->mSampler); + bindSampledImage(LLRender::NORMAL_MAP, *prepared->mSampledImages[1], *prepared->mSampler); + bindSampledImage(LLRender::SPECULAR_MAP, *prepared->mSampledImages[2], *prepared->mSampler); + + prepared->mVertexBuffer->setBuffer(); + prepared->mVertexBuffer->drawRange(LLRender::TRIANGLES, 0, 3, 6, 0); + success = noGlError(); + + for (U32 unit : { U32{ LLRender::DIFFUSE_MAP }, U32{ LLRender::NORMAL_MAP }, U32{ LLRender::SPECULAR_MAP } }) + { + glBindSampler(unit, 0); + gGL.getTexUnit(unit)->unbind(LLTexUnit::TT_TEXTURE); + } + LLGLSLShader::unbind(); + } + prepared->mTarget->flush(); + return success; +} + +} // namespace LLRenderGLMaterial diff --git a/indra/llrender/llrenderglmaterial.h b/indra/llrender/llrenderglmaterial.h new file mode 100644 index 00000000000..8847468d2d3 --- /dev/null +++ b/indra/llrender/llrenderglmaterial.h @@ -0,0 +1,260 @@ +/** + * @file llrenderglmaterial.h + * @brief Narrow OpenGL registry and executor for the Stage 12 material draw. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERGLMATERIAL_H +#define LL_LLRENDERGLMATERIAL_H + +#include "llmaterialcontract.h" +#include "stdtypes.h" +#include "llgltypes.h" + +#include +#include +#include +#include +#include +#include +#include + +class LLGLSLShader; +class LLRenderTarget; +class LLVertexBuffer; + +namespace LLRenderGLMaterial +{ + +struct SampledImage +{ + LLGLuint mTexture = 0; + LLRenderContract::PixelFormat mFormat = LLRenderContract::PixelFormat::RGBA8Unorm; + LLRenderContract::Extent2D mExtent; + std::uint32_t mMipLevels = 0; + + friend constexpr bool operator==(const SampledImage& left, const SampledImage& right) + { + return left.mTexture == right.mTexture && left.mFormat == right.mFormat && left.mExtent.mWidth == right.mExtent.mWidth && + left.mExtent.mHeight == right.mExtent.mHeight && left.mMipLevels == right.mMipLevels; + } +}; + +struct Sampler +{ + LLRenderContract::Filter mMinFilter = LLRenderContract::Filter::Linear; + LLRenderContract::Filter mMagFilter = LLRenderContract::Filter::Linear; + LLRenderContract::MipFilter mMipFilter = LLRenderContract::MipFilter::Linear; + LLRenderContract::AddressMode mAddressU = LLRenderContract::AddressMode::Repeat; + LLRenderContract::AddressMode mAddressV = LLRenderContract::AddressMode::Repeat; + float mMaxAnisotropy = 8.f; + + friend constexpr bool operator==(const Sampler&, const Sampler&) = default; +}; + +enum class TargetAspect +{ + Color, + Depth +}; + +struct TargetImage +{ + LLRenderTarget* mTarget = nullptr; + TargetAspect mAspect = TargetAspect::Color; + std::uint32_t mAttachment = 0; +}; + +// The registry borrows viewer objects for one synchronous replay. It owns no GL +// object and never dereferences a viewer pointer during registration/resolution. +class Registry +{ +public: + bool addVertexBuffer(LLRenderContract::BufferHandle vertex_handle, LLRenderContract::BufferHandle index_handle, LLVertexBuffer* buffer) + { + if (mBuffer.mBuffer || !vertex_handle || !index_handle || !buffer || vertex_handle.mIndex == index_handle.mIndex) + { + return false; + } + mBuffer = { vertex_handle, index_handle, buffer }; + return true; + } + + bool addSampledImage(LLRenderContract::ImageHandle handle, SampledImage image) + { + if (!handle || image.mTexture == 0 || image.mExtent.mWidth == 0 || image.mExtent.mHeight == 0 || image.mMipLevels == 0 || + hasImageIndex(handle.mIndex) || hasTexture(image.mTexture)) + { + return false; + } + mSampledImages.push_back({ handle, image }); + return true; + } + + bool addRenderTarget(const std::array& colors, + LLRenderContract::ImageHandle depth, + LLRenderTarget* target) + { + if (mTarget.mTarget || !target || !depth) + { + return false; + } + + std::array indices{ colors[0].mIndex, colors[1].mIndex, colors[2].mIndex, depth.mIndex }; + if (!colors[0] || !colors[1] || !colors[2] || + std::any_of(indices.begin(), indices.end(), [this](std::uint32_t index) { return hasImageIndex(index); })) + { + return false; + } + std::sort(indices.begin(), indices.end()); + if (std::adjacent_find(indices.begin(), indices.end()) != indices.end()) + { + return false; + } + + mTarget = { colors, depth, target }; + return true; + } + + bool addSampler(LLRenderContract::SamplerHandle handle, Sampler sampler) + { + if (mSampler.mPresent || !handle || !std::isfinite(sampler.mMaxAnisotropy) || sampler.mMaxAnisotropy < 1.f) + { + return false; + } + mSampler = { handle, sampler, true }; + return true; + } + + bool addPipeline(LLRenderContract::PipelineHandle handle, LLRenderContract::ShaderProgramKey program, LLGLSLShader* shader) + { + if (mPipeline.mShader || !handle || !shader || program.mName.empty()) + { + return false; + } + mPipeline = { handle, std::move(program), shader }; + return true; + } + + LLVertexBuffer* resolveVertexBuffer(LLRenderContract::BufferHandle vertex_handle, LLRenderContract::BufferHandle index_handle) const + { + return mBuffer.mVertexHandle == vertex_handle && mBuffer.mIndexHandle == index_handle ? mBuffer.mBuffer : nullptr; + } + + LLVertexBuffer* resolve(LLRenderContract::BufferHandle handle) const + { + return mBuffer.mVertexHandle == handle || mBuffer.mIndexHandle == handle ? mBuffer.mBuffer : nullptr; + } + + const SampledImage* resolveSampledImage(LLRenderContract::ImageHandle handle) const + { + const auto found = std::find_if(mSampledImages.begin(), mSampledImages.end(), + [handle](const SampledImageEntry& entry) { return entry.mHandle == handle; }); + return found == mSampledImages.end() ? nullptr : &found->mImage; + } + + TargetImage resolveTargetImage(LLRenderContract::ImageHandle handle) const + { + if (mTarget.mDepth == handle) + { + return { mTarget.mTarget, TargetAspect::Depth, 0 }; + } + for (std::uint32_t attachment = 0; attachment < mTarget.mColors.size(); ++attachment) + { + if (mTarget.mColors[attachment] == handle) + { + return { mTarget.mTarget, TargetAspect::Color, attachment }; + } + } + return {}; + } + + const Sampler* resolve(LLRenderContract::SamplerHandle handle) const + { + return mSampler.mPresent && mSampler.mHandle == handle ? &mSampler.mSampler : nullptr; + } + + LLGLSLShader* resolve(LLRenderContract::PipelineHandle handle, const LLRenderContract::ShaderProgramKey& program) const + { + return mPipeline.mHandle == handle && mPipeline.mProgram.mName == program.mName && mPipeline.mProgram.mVariant == program.mVariant + ? mPipeline.mShader + : nullptr; + } + +private: + struct BufferEntry + { + LLRenderContract::BufferHandle mVertexHandle; + LLRenderContract::BufferHandle mIndexHandle; + LLVertexBuffer* mBuffer = nullptr; + }; + + struct SampledImageEntry + { + LLRenderContract::ImageHandle mHandle; + SampledImage mImage; + }; + + struct TargetEntry + { + std::array mColors{}; + LLRenderContract::ImageHandle mDepth; + LLRenderTarget* mTarget = nullptr; + }; + + struct SamplerEntry + { + LLRenderContract::SamplerHandle mHandle; + Sampler mSampler; + bool mPresent = false; + }; + + struct PipelineEntry + { + LLRenderContract::PipelineHandle mHandle; + LLRenderContract::ShaderProgramKey mProgram; + LLGLSLShader* mShader = nullptr; + }; + + bool hasImageIndex(std::uint32_t index) const + { + if (std::any_of(mSampledImages.begin(), mSampledImages.end(), + [index](const SampledImageEntry& entry) { return entry.mHandle.mIndex == index; })) + { + return true; + } + return std::any_of(mTarget.mColors.begin(), mTarget.mColors.end(), + [index](LLRenderContract::ImageHandle handle) { return handle.mIndex == index; }) || + mTarget.mDepth.mIndex == index; + } + + bool hasTexture(LLGLuint texture) const + { + return std::any_of(mSampledImages.begin(), mSampledImages.end(), + [texture](const SampledImageEntry& entry) { return entry.mImage.mTexture == texture; }); + } + + BufferEntry mBuffer; + std::vector mSampledImages; + TargetEntry mTarget; + SamplerEntry mSampler; + PipelineEntry mPipeline; +}; + +// Decodes and validates the complete fixed packet and every live GL resource +// before binding or clearing its render target. Returns false without drawing +// when any explicit or implicit Stage 12 invariant is absent. +bool execute(const LLRenderContract::FrameSnapshot& frame, const Registry& registry); + +} // namespace LLRenderGLMaterial + +#endif // LL_LLRENDERGLMATERIAL_H diff --git a/indra/llrender/llrendergltextureupload.cpp b/indra/llrender/llrendergltextureupload.cpp new file mode 100644 index 00000000000..4b34315f073 --- /dev/null +++ b/indra/llrender/llrendergltextureupload.cpp @@ -0,0 +1,1360 @@ +/** + * @file llrendergltextureupload.cpp + * @brief OpenGL replay of the canonical streamed texture upload. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendergltextureupload.h" + +#include "llgl.h" +#include "llglslshader.h" +#include "llimage.h" +#include "llimagegl.h" +#include "llrender.h" +#include "llrendertarget.h" +#include "llshadermgr.h" +#include "llthread.h" +#include "llvertexbuffer.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace LLRenderGLTextureUpload +{ + +bool Registry::addScreenTriangle(LLRenderContract::BufferHandle handle, LLVertexBuffer* buffer) +{ + if (mScreenTriangle || !handle || !buffer) + { + return false; + } + mScreenHandle = handle; + mScreenTriangle = buffer; + return true; +} + +bool Registry::addImageGenerations(LLRenderContract::ImageHandle old_handle, LLImageGL* old_image, + LLRenderContract::ImageHandle replacement_handle, LLImageGL* replacement_image) +{ + if (mOldImage || mReplacementImage || !old_handle || !replacement_handle || !old_image || !replacement_image || + old_image == replacement_image || old_handle.mIndex != replacement_handle.mIndex || + old_handle.mGeneration == std::numeric_limits::max() || + replacement_handle.mGeneration != old_handle.mGeneration + 1 || + (mOutput && mOutputHandle.mIndex == old_handle.mIndex) || + (mLifecycle && mLifecycle->mCurrentImage != old_handle)) + { + return false; + } + + mOldHandle = old_handle; + mOldImage = old_image; + mReplacementHandle = replacement_handle; + mReplacementImage = replacement_image; + return true; +} + +bool Registry::addOutput(LLRenderContract::ImageHandle handle, LLRenderTarget* output) +{ + if (mOutput || !handle || !output || (mOldImage && handle.mIndex == mOldHandle.mIndex)) + { + return false; + } + mOutputHandle = handle; + mOutput = output; + return true; +} + +bool Registry::addSampler(LLRenderContract::SamplerHandle handle, Sampler sampler) +{ + const bool canonical = sampler.mName != 0 && sampler.mMinFilter == LLRenderContract::Filter::Linear && + sampler.mMagFilter == LLRenderContract::Filter::Linear && + sampler.mMipFilter == LLRenderContract::MipFilter::Linear && + sampler.mAddressU == LLRenderContract::AddressMode::Clamp && + sampler.mAddressV == LLRenderContract::AddressMode::Clamp && + std::isfinite(sampler.mMaxAnisotropy) && sampler.mMaxAnisotropy == 1.f; + if (mSamplerHandle || !handle || !canonical) + { + return false; + } + mSamplerHandle = handle; + mSampler = sampler; + return true; +} + +bool Registry::addPipeline(LLRenderContract::PipelineHandle handle, LLRenderContract::ShaderProgramKey program, + LLGLSLShader* shader) +{ + if (mShader || !handle || !shader || program.mName != "contract.sample-texture" || program.mVariant != 0) + { + return false; + } + mPipelineHandle = handle; + mProgram = std::move(program); + mShader = shader; + return true; +} + +bool Registry::addLifecycle(LifecycleLedger* ledger) +{ + if (mLifecycle || !ledger || !ledger->mCurrentImage || ledger->mCompletionPending || ledger->mCompletionCount != 0 || + ledger->mRetirementCount != 0 || ledger->mCompletedDestination || ledger->mRetiredResource || + ledger->mCompletedRevision != 0 || ledger->mCompletedFrame != 0 || ledger->mRetirementFrame != 0 || + (mOldImage && ledger->mCurrentImage != mOldHandle)) + { + return false; + } + mLifecycle = ledger; + return true; +} + +LLVertexBuffer* Registry::resolve(LLRenderContract::BufferHandle handle) const +{ + return mScreenHandle == handle ? mScreenTriangle : nullptr; +} + +LLImageGL* Registry::resolveRegisteredImage(LLRenderContract::ImageHandle handle) const +{ + if (mOldHandle == handle) + { + return mOldImage; + } + return mReplacementHandle == handle ? mReplacementImage : nullptr; +} + +LLRenderTarget* Registry::resolveOutput(LLRenderContract::ImageHandle handle) const +{ + return mOutputHandle == handle ? mOutput : nullptr; +} + +const Sampler* Registry::resolve(LLRenderContract::SamplerHandle handle) const +{ + return mSamplerHandle == handle ? &mSampler : nullptr; +} + +LLGLSLShader* Registry::resolve(LLRenderContract::PipelineHandle handle, + const LLRenderContract::ShaderProgramKey& program) const +{ + return mPipelineHandle == handle && mProgram.mName == program.mName && mProgram.mVariant == program.mVariant ? mShader : nullptr; +} + +LifecycleLedger* Registry::lifecycle() const +{ + return mLifecycle; +} + +bool Registry::isResolvable(LLRenderContract::ImageHandle handle) const +{ + return mLifecycle && mLifecycle->mCurrentImage == handle && resolveRegisteredImage(handle); +} + +namespace +{ + +using namespace LLRenderContract; + +constexpr U32 SCREEN_VERTEX_MASK = LLVertexBuffer::MAP_VERTEX; +constexpr std::uint64_t COMPLETION_TIMEOUT_NS = 5'000'000'000ULL; + +struct Prepared +{ + StreamingUploadInputs mInputs; + LLVertexBuffer* mScreenTriangle = nullptr; + LLImageGL* mOldImage = nullptr; + LLImageGL* mReplacementImage = nullptr; + LLRenderTarget* mOutput = nullptr; + const Sampler* mSampler = nullptr; + LLGLSLShader* mShader = nullptr; + LifecycleLedger* mLifecycle = nullptr; +}; + +bool noGlError() +{ + bool clean = true; + while (glGetError() != GL_NO_ERROR) + { + clean = false; + } + return clean; +} + +void setEnabled(GLenum capability, bool enabled) +{ + if (enabled) + { + glEnable(capability); + } + else + { + glDisable(capability); + } +} + +GLenum textureBindingQuery(LLTexUnit::eTextureType type) +{ + switch (type) + { + case LLTexUnit::TT_TEXTURE: return GL_TEXTURE_BINDING_2D; + case LLTexUnit::TT_RECT_TEXTURE: return GL_TEXTURE_BINDING_RECTANGLE; + case LLTexUnit::TT_CUBE_MAP: return GL_TEXTURE_BINDING_CUBE_MAP; + case LLTexUnit::TT_CUBE_MAP_ARRAY: return GL_TEXTURE_BINDING_CUBE_MAP_ARRAY; + case LLTexUnit::TT_MULTISAMPLE_TEXTURE: return GL_TEXTURE_BINDING_2D_MULTISAMPLE; + case LLTexUnit::TT_TEXTURE_3D: return GL_TEXTURE_BINDING_3D; + case LLTexUnit::TT_NONE: return GL_NONE; + } + return GL_NONE; +} + +// Captures all state touched by live-resource inspection, upload, draw, and +// readback. Resource contents are intentionally outside this state snapshot. +class GLStateRestore +{ +public: + GLStateRestore() + { + glGetIntegerv(GL_ACTIVE_TEXTURE, &mActiveTexture); + mCachedTextureUnitIndex = gGL.getCurrentTexUnitIndex(); + glActiveTexture(GL_TEXTURE0); + glGetIntegerv(GL_TEXTURE_BINDING_2D, &mTexture2D); + glGetIntegerv(GL_SAMPLER_BINDING, &mSampler); + LLTexUnit* unit = gGL.getTexUnit(0); + mCachedTexture = unit->getCurrTexture(); + mCachedTextureType = unit->getCurrType(); + mCachedTextureHasMips = unit->getHasMipMaps(); + const GLenum cached_binding_query = textureBindingQuery(mCachedTextureType); + if (cached_binding_query != GL_NONE) + { + mCachedTextureTarget = LLTexUnit::getInternalType(mCachedTextureType); + glGetIntegerv(cached_binding_query, &mRawCachedTargetTexture); + } + + glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &mPackBuffer); + glGetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, &mUnpackBuffer); + capturePixelStore(); +#ifdef GL_PACK_INVERT_MESA + mHasPackInvert = gGLManager.mGLExtensions.contains("GL_MESA_pack_invert"); + if (mHasPackInvert) + { + glGetIntegerv(GL_PACK_INVERT_MESA, &mPackInvert); + } +#endif +#ifdef GL_UNPACK_CLIENT_STORAGE_APPLE + mHasClientStorage = gGLManager.mGLExtensions.contains("GL_APPLE_client_storage"); + if (mHasClientStorage) + { + glGetIntegerv(GL_UNPACK_CLIENT_STORAGE_APPLE, &mClientStorage); + } +#endif + + glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &mArrayBuffer); + glGetIntegerv(GL_ELEMENT_ARRAY_BUFFER_BINDING, &mElementBuffer); + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &mVertexArray); + mCachedArrayBuffer = LLVertexBuffer::sGLRenderBuffer; + mCachedElementBuffer = LLVertexBuffer::sGLRenderIndices; + mCachedAttributeMask = LLVertexBuffer::sLastMask; + glGenVertexArrays(1, &mExecutionVertexArray); + + glGetIntegerv(GL_CURRENT_PROGRAM, &mProgram); + mCachedProgram = LLGLSLShader::sCurBoundShader; + mCachedShader = LLGLSLShader::sCurBoundShaderPtr; + + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &mDrawFramebuffer); + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &mReadFramebuffer); + glGetIntegerv(GL_READ_BUFFER, &mReadBuffer); + GLint draw_buffer_count = 0; + glGetIntegerv(GL_MAX_DRAW_BUFFERS, &draw_buffer_count); + mDrawBuffers.resize(static_cast(std::max(1, draw_buffer_count)), GL_NONE); + for (GLint index = 0; index < draw_buffer_count; ++index) + { + glGetIntegerv(static_cast(GL_DRAW_BUFFER0 + index), &mDrawBuffers[static_cast(index)]); + } + mBoundTarget = LLRenderTarget::sBoundTarget; + mCachedFramebuffer = LLRenderTarget::sCurFBO; + mCachedWidth = LLRenderTarget::sCurResX; + mCachedHeight = LLRenderTarget::sCurResY; + + glGetIntegerv(GL_VIEWPORT, mViewport.data()); + glGetIntegerv(GL_SCISSOR_BOX, mScissor.data()); + glGetBooleanv(GL_COLOR_WRITEMASK, mColorMask.data()); +#if !LL_DARWIN + mHasClipControl = gGLManager.mGLVersion >= 4.49f && glClipControl; + if (mHasClipControl) + { + glGetIntegerv(GL_CLIP_ORIGIN, &mClipOrigin); + glGetIntegerv(GL_CLIP_DEPTH_MODE, &mClipDepthMode); + } +#endif + GLint profile_mask = 0; + glGetIntegerv(GL_CONTEXT_PROFILE_MASK, &profile_mask); + mCoreProfile = (profile_mask & GL_CONTEXT_CORE_PROFILE_BIT) != 0; + glGetIntegerv(GL_POLYGON_MODE, mPolygonMode.data()); + if (mCoreProfile) + { + mPolygonMode[1] = mPolygonMode[0]; + } + + constexpr std::array capabilities{ GL_BLEND, + GL_CULL_FACE, + GL_DEPTH_TEST, + GL_STENCIL_TEST, + GL_SCISSOR_TEST, + GL_DITHER, + GL_FRAMEBUFFER_SRGB, + GL_RASTERIZER_DISCARD, + GL_MULTISAMPLE, + GL_SAMPLE_ALPHA_TO_COVERAGE, + GL_SAMPLE_ALPHA_TO_ONE, + GL_SAMPLE_COVERAGE, + GL_SAMPLE_MASK, + GL_SAMPLE_SHADING, + GL_COLOR_LOGIC_OP }; + for (GLenum capability : capabilities) + { + mCapabilities.emplace(capability, glIsEnabled(capability) == GL_TRUE); + } + GLint clip_distance_count = 0; + glGetIntegerv(GL_MAX_CLIP_DISTANCES, &clip_distance_count); + for (GLint index = 0; index < clip_distance_count; ++index) + { + const GLenum capability = static_cast(GL_CLIP_DISTANCE0 + index); + mCapabilities.emplace(capability, glIsEnabled(capability) == GL_TRUE); + } + mValid = mExecutionVertexArray != 0 && noGlError() && + mActiveTexture == static_cast(GL_TEXTURE0 + mCachedTextureUnitIndex); + } + + GLStateRestore(const GLStateRestore&) = delete; + GLStateRestore& operator=(const GLStateRestore&) = delete; + + ~GLStateRestore() { restore(); } + + bool restore() + { + if (mRestored) + { + return mRestoreSucceeded; + } + mRestored = true; + restoreFramebuffer(); + + glUseProgram(static_cast(mProgram)); + LLGLSLShader::sCurBoundShader = mCachedProgram; + LLGLSLShader::sCurBoundShaderPtr = mCachedShader; + + glBindVertexArray(static_cast(mVertexArray)); + glBindBuffer(GL_ARRAY_BUFFER, static_cast(mArrayBuffer)); + LLVertexBuffer::sGLRenderBuffer = mCachedArrayBuffer; + LLVertexBuffer::sGLRenderIndices = mCachedElementBuffer; + LLVertexBuffer::sLastMask = mCachedAttributeMask; + if (mExecutionVertexArray != 0) + { + glDeleteVertexArrays(1, &mExecutionVertexArray); + mExecutionVertexArray = 0; + } + + restorePixelStore(); +#ifdef GL_PACK_INVERT_MESA + if (mHasPackInvert) + { + glPixelStorei(GL_PACK_INVERT_MESA, mPackInvert); + } +#endif +#ifdef GL_UNPACK_CLIENT_STORAGE_APPLE + if (mHasClientStorage) + { + glPixelStorei(GL_UNPACK_CLIENT_STORAGE_APPLE, mClientStorage); + } +#endif + glBindBuffer(GL_PIXEL_PACK_BUFFER, static_cast(mPackBuffer)); + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, static_cast(mUnpackBuffer)); + + glActiveTexture(GL_TEXTURE0); + if (mCachedTexture != 0) + { + gGL.getTexUnit(0)->bindManual(mCachedTextureType, mCachedTexture, mCachedTextureHasMips); + } + else + { + if (mCachedTextureType == LLTexUnit::TT_NONE) + { + gGL.getTexUnit(0)->disable(); + } + else + { + gGL.getTexUnit(0)->enable(mCachedTextureType); + gGL.getTexUnit(0)->unbind(mCachedTextureType); + } + } + gGL.getTexUnit(0)->setHasMipMaps(mCachedTextureHasMips); + glBindTexture(GL_TEXTURE_2D, static_cast(mTexture2D)); + if (mCachedTextureTarget != GL_NONE && mCachedTextureTarget != GL_TEXTURE_2D) + { + glBindTexture(mCachedTextureTarget, static_cast(mRawCachedTargetTexture)); + } + glBindSampler(0, static_cast(mSampler)); + gGL.getTexUnit(mCachedTextureUnitIndex)->activate(); + glActiveTexture(static_cast(mActiveTexture)); + + glViewport(mViewport[0], mViewport[1], mViewport[2], mViewport[3]); + glScissor(mScissor[0], mScissor[1], mScissor[2], mScissor[3]); + glColorMask(mColorMask[0], mColorMask[1], mColorMask[2], mColorMask[3]); +#if !LL_DARWIN + if (mHasClipControl) + { + glClipControl(static_cast(mClipOrigin), static_cast(mClipDepthMode)); + } +#endif + if (mCoreProfile) + { + glPolygonMode(GL_FRONT_AND_BACK, static_cast(mPolygonMode[0])); + } + else + { + glPolygonMode(GL_FRONT, static_cast(mPolygonMode[0])); + glPolygonMode(GL_BACK, static_cast(mPolygonMode[1])); + } + for (const auto& [capability, enabled] : mCapabilities) + { + setEnabled(capability, enabled); + } + mRestoreSucceeded = noGlError(); + return mRestoreSucceeded; + } + + bool valid() const { return mValid; } + + void isolateViewerCaches() + { + glBindVertexArray(mExecutionVertexArray); + LLTexUnit* unit = gGL.getTexUnit(0); + unit->enable(LLTexUnit::TT_TEXTURE); + unit->unbind(LLTexUnit::TT_TEXTURE); + glBindTexture(GL_TEXTURE_2D, 0); + glBindSampler(0, 0); + + glUseProgram(0); + LLGLSLShader::sCurBoundShader = 0; + LLGLSLShader::sCurBoundShaderPtr = nullptr; + glBindBuffer(GL_ARRAY_BUFFER, 0); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, 0); + LLVertexBuffer::sGLRenderBuffer = 0; + LLVertexBuffer::sGLRenderIndices = 0; + LLVertexBuffer::sLastMask = 0; + } + + void setTightPixelTransfer() const + { + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glPixelStorei(GL_PACK_ROW_LENGTH, 0); + glPixelStorei(GL_PACK_SKIP_ROWS, 0); + glPixelStorei(GL_PACK_SKIP_PIXELS, 0); + glPixelStorei(GL_PACK_IMAGE_HEIGHT, 0); + glPixelStorei(GL_PACK_SKIP_IMAGES, 0); + glPixelStorei(GL_PACK_SWAP_BYTES, GL_FALSE); + glPixelStorei(GL_PACK_LSB_FIRST, GL_FALSE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + glPixelStorei(GL_UNPACK_SKIP_ROWS, 0); + glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0); + glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0); + glPixelStorei(GL_UNPACK_SKIP_IMAGES, 0); + glPixelStorei(GL_UNPACK_SWAP_BYTES, GL_FALSE); + glPixelStorei(GL_UNPACK_LSB_FIRST, GL_FALSE); +#ifdef GL_PACK_INVERT_MESA + if (mHasPackInvert) + { + glPixelStorei(GL_PACK_INVERT_MESA, GL_FALSE); + } +#endif +#ifdef GL_UNPACK_CLIENT_STORAGE_APPLE + if (mHasClientStorage) + { + glPixelStorei(GL_UNPACK_CLIENT_STORAGE_APPLE, GL_FALSE); + } +#endif + } + +private: + void capturePixelStore() + { + constexpr std::array names{ GL_PACK_ALIGNMENT, GL_PACK_ROW_LENGTH, GL_PACK_SKIP_ROWS, + GL_PACK_SKIP_PIXELS, GL_PACK_IMAGE_HEIGHT, GL_PACK_SKIP_IMAGES, + GL_PACK_SWAP_BYTES, GL_PACK_LSB_FIRST, GL_UNPACK_ALIGNMENT, + GL_UNPACK_ROW_LENGTH, GL_UNPACK_SKIP_ROWS, GL_UNPACK_SKIP_PIXELS, + GL_UNPACK_IMAGE_HEIGHT, GL_UNPACK_SKIP_IMAGES, GL_UNPACK_SWAP_BYTES, + GL_UNPACK_LSB_FIRST }; + for (std::size_t index = 0; index < names.size(); ++index) + { + glGetIntegerv(names[index], &mPixelStore[index]); + } + } + + void restorePixelStore() const + { + constexpr std::array names{ GL_PACK_ALIGNMENT, GL_PACK_ROW_LENGTH, GL_PACK_SKIP_ROWS, + GL_PACK_SKIP_PIXELS, GL_PACK_IMAGE_HEIGHT, GL_PACK_SKIP_IMAGES, + GL_PACK_SWAP_BYTES, GL_PACK_LSB_FIRST, GL_UNPACK_ALIGNMENT, + GL_UNPACK_ROW_LENGTH, GL_UNPACK_SKIP_ROWS, GL_UNPACK_SKIP_PIXELS, + GL_UNPACK_IMAGE_HEIGHT, GL_UNPACK_SKIP_IMAGES, GL_UNPACK_SWAP_BYTES, + GL_UNPACK_LSB_FIRST }; + for (std::size_t index = 0; index < names.size(); ++index) + { + glPixelStorei(names[index], mPixelStore[index]); + } + } + + void restoreFramebuffer() const + { + glBindFramebuffer(GL_DRAW_FRAMEBUFFER, static_cast(mDrawFramebuffer)); + if (mDrawFramebuffer == 0) + { + glDrawBuffer(static_cast(mDrawBuffers.front())); + } + else + { + std::vector buffers; + buffers.reserve(mDrawBuffers.size()); + std::transform(mDrawBuffers.begin(), mDrawBuffers.end(), std::back_inserter(buffers), + [](GLint value) { return static_cast(value); }); + glDrawBuffers(static_cast(buffers.size()), buffers.data()); + } + glBindFramebuffer(GL_READ_FRAMEBUFFER, static_cast(mReadFramebuffer)); + glReadBuffer(static_cast(mReadBuffer)); + LLRenderTarget::sBoundTarget = mBoundTarget; + LLRenderTarget::sCurFBO = static_cast(mCachedFramebuffer); + LLRenderTarget::sCurResX = static_cast(mCachedWidth); + LLRenderTarget::sCurResY = static_cast(mCachedHeight); + } + + bool mValid = false; + bool mRestored = false; + bool mRestoreSucceeded = false; + GLint mActiveTexture = GL_TEXTURE0; + U32 mCachedTextureUnitIndex = 0; + GLint mTexture2D = 0; + GLint mSampler = 0; + U32 mCachedTexture = 0; + LLTexUnit::eTextureType mCachedTextureType = LLTexUnit::TT_TEXTURE; + bool mCachedTextureHasMips = false; + GLenum mCachedTextureTarget = GL_NONE; + GLint mRawCachedTargetTexture = 0; + + GLint mPackBuffer = 0; + GLint mUnpackBuffer = 0; + std::array mPixelStore{}; +#ifdef GL_PACK_INVERT_MESA + bool mHasPackInvert = false; + GLint mPackInvert = GL_FALSE; +#endif +#ifdef GL_UNPACK_CLIENT_STORAGE_APPLE + bool mHasClientStorage = false; + GLint mClientStorage = GL_FALSE; +#endif + + GLint mArrayBuffer = 0; + GLint mElementBuffer = 0; + GLint mVertexArray = 0; + GLuint mExecutionVertexArray = 0; + U32 mCachedArrayBuffer = 0; + U32 mCachedElementBuffer = 0; + U32 mCachedAttributeMask = 0; + + GLint mProgram = 0; + GLuint mCachedProgram = 0; + LLGLSLShader* mCachedShader = nullptr; + + GLint mDrawFramebuffer = 0; + GLint mReadFramebuffer = 0; + GLint mReadBuffer = GL_BACK; + std::vector mDrawBuffers; + LLRenderTarget* mBoundTarget = nullptr; + GLint mCachedFramebuffer = 0; + GLint mCachedWidth = 0; + GLint mCachedHeight = 0; + + std::array mViewport{}; + std::array mScissor{}; + std::array mColorMask{}; +#if !LL_DARWIN + bool mHasClipControl = false; + GLint mClipOrigin = GL_LOWER_LEFT; + GLint mClipDepthMode = GL_NEGATIVE_ONE_TO_ONE; +#endif + std::array mPolygonMode{}; + bool mCoreProfile = false; + std::map mCapabilities; +}; + +struct ActiveVariable +{ + GLint mLocation = -1; + GLint mSize = 0; + GLenum mType = 0; +}; + +using ActiveVariables = std::map; + +std::optional activeVariables(GLuint program, GLenum count_name, GLenum max_length_name, bool attributes) +{ + GLint count = 0; + GLint max_length = 0; + glGetProgramiv(program, count_name, &count); + glGetProgramiv(program, max_length_name, &max_length); + if (count < 0 || max_length <= 0) + { + return std::nullopt; + } + + std::vector name(static_cast(max_length)); + ActiveVariables result; + for (GLint index = 0; index < count; ++index) + { + GLsizei length = 0; + GLint size = 0; + GLenum type = 0; + if (attributes) + { + glGetActiveAttrib(program, static_cast(index), max_length, &length, &size, &type, name.data()); + } + else + { + glGetActiveUniform(program, static_cast(index), max_length, &length, &size, &type, name.data()); + } + if (length <= 0) + { + return std::nullopt; + } + std::string variable(name.data(), static_cast(length)); + if (!attributes && variable.ends_with("[0]")) + { + variable.resize(variable.size() - 3); + } + const GLint location = attributes ? glGetAttribLocation(program, variable.c_str()) + : glGetUniformLocation(program, variable.c_str()); + if (location < 0 || !result.emplace(std::move(variable), ActiveVariable{ location, size, type }).second) + { + return std::nullopt; + } + } + return result; +} + +bool matchesShader(LLGLSLShader& shader) +{ + const std::vector> expected_files{ { "interface/copyV.glsl", GL_VERTEX_SHADER }, + { "interface/copyF.glsl", GL_FRAGMENT_SHADER } }; + if (!shader.isComplete() || shader.mName != "Copy Shader" || shader.mShaderFiles != expected_files || !shader.mDefines.empty() || + shader.mProgramObject == 0 || !glIsProgram(shader.mProgramObject) || + shader.mAttributeMask != SCREEN_VERTEX_MASK || shader.mActiveTextureChannels != 1 || + shader.getTextureChannel(LLShaderMgr::DIFFUSE_MAP) != 0) + { + return false; + } + + GLint linked = GL_FALSE; + glGetProgramiv(shader.mProgramObject, GL_LINK_STATUS, &linked); + const auto attributes = activeVariables(shader.mProgramObject, GL_ACTIVE_ATTRIBUTES, GL_ACTIVE_ATTRIBUTE_MAX_LENGTH, true); + const auto uniforms = activeVariables(shader.mProgramObject, GL_ACTIVE_UNIFORMS, GL_ACTIVE_UNIFORM_MAX_LENGTH, false); + if (linked != GL_TRUE || !attributes || !uniforms || attributes->size() != 1 || uniforms->size() != 1) + { + return false; + } + + const auto position = attributes->find("position"); + const auto diffuse = uniforms->find("diffuseMap"); + return position != attributes->end() && position->second.mLocation == LLVertexBuffer::TYPE_VERTEX && + position->second.mSize == 1 && position->second.mType == GL_FLOAT_VEC3 && diffuse != uniforms->end() && + diffuse->second.mSize == 1 && diffuse->second.mType == GL_SAMPLER_2D && + shader.getUniformLocation(LLShaderMgr::DIFFUSE_MAP) == diffuse->second.mLocation && + glGetFragDataLocation(shader.mProgramObject, "frag_color") == 0 && noGlError(); +} + +bool matchesImageMetadata(const LLImageGL& image) +{ + return image.getCurrentWidth() == static_cast(TEXTURE_UPLOAD_LOGICAL_WIDTH) && + image.getCurrentHeight() == static_cast(TEXTURE_UPLOAD_LOGICAL_HEIGHT) && + image.getWidth() == static_cast(TEXTURE_UPLOAD_RESIDENT_WIDTH) && + image.getHeight() == static_cast(TEXTURE_UPLOAD_RESIDENT_HEIGHT) && + image.getDiscardLevel() == static_cast(TEXTURE_UPLOAD_RESIDENT_DISCARD) && image.getMaxDiscardLevel() == 4 && + image.getComponents() == TEXTURE_UPLOAD_CHANNELS && image.getUseMipMaps() && image.getHasExplicitFormat() && + image.getPrimaryFormat() == GL_RGBA && image.getFormatType() == GL_UNSIGNED_BYTE && image.getTexTarget() == GL_TEXTURE_2D && + image.getTarget() == LLTexUnit::TT_TEXTURE && image.getHasGLTexture() && image.isGLTextureCreated() && image.getTexName() != 0; +} + +void bindPreservingUniformDirty(LLGLSLShader& shader) +{ + const bool uniforms_dirty = shader.mUniformsDirty; + shader.mUniformsDirty = false; + shader.bind(); + shader.mUniformsDirty = uniforms_dirty; +} + +bool matchesTextureLevels(GLuint texture, std::uint32_t width, std::uint32_t height, std::uint32_t levels, + bool exact_mip_range, std::string* error = nullptr, + GLenum alternate_internal_format = GL_NONE) +{ + const auto fail = [error](std::string reason) + { + if (error) + { + *error = std::move(reason); + } + return false; + }; + if (texture == 0 || !glIsTexture(texture)) + { + return fail("name"); + } + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, texture); + + GLint base_level = -1; + GLint max_level = -1; + GLint swizzle_r = 0; + GLint swizzle_g = 0; + GLint swizzle_b = 0; + GLint swizzle_a = 0; + glGetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, &base_level); + glGetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, &max_level); + glGetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, &swizzle_r); + glGetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_G, &swizzle_g); + glGetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, &swizzle_b); + glGetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, &swizzle_a); + if (base_level != 0 || (exact_mip_range && max_level != static_cast(levels - 1)) || + swizzle_r != GL_RED || swizzle_g != GL_GREEN || swizzle_b != GL_BLUE || swizzle_a != GL_ALPHA) + { + return fail("parameters"); + } + + for (std::uint32_t level = 0; level < levels; ++level) + { + GLint level_width = 0; + GLint level_height = 0; + GLint internal_format = 0; + GLint compressed = GL_TRUE; + GLint red_size = 0; + GLint green_size = 0; + GLint blue_size = 0; + GLint alpha_size = 0; + GLint red_type = 0; + GLint green_type = 0; + GLint blue_type = 0; + GLint alpha_type = 0; + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_WIDTH, &level_width); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_HEIGHT, &level_height); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_INTERNAL_FORMAT, &internal_format); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_COMPRESSED, &compressed); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_RED_SIZE, &red_size); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_GREEN_SIZE, &green_size); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_BLUE_SIZE, &blue_size); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_ALPHA_SIZE, &alpha_size); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_RED_TYPE, &red_type); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_GREEN_TYPE, &green_type); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_BLUE_TYPE, &blue_type); + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(level), GL_TEXTURE_ALPHA_TYPE, &alpha_type); + + const GLint expected_width = static_cast(std::max(1, width >> level)); + const GLint expected_height = static_cast(std::max(1, height >> level)); + const std::string prefix = "level_" + std::to_string(level) + '_'; + if (level_width != expected_width || level_height != expected_height) return fail(prefix + "extent"); + if (internal_format != GL_RGBA8 && internal_format != static_cast(alternate_internal_format)) + { + return fail(prefix + "format_" + std::to_string(internal_format)); + } + if (compressed != GL_FALSE) return fail(prefix + "compressed"); + if (red_size != 8 || green_size != 8 || blue_size != 8 || alpha_size != 8) + { + return fail(prefix + "component_size"); + } + if (red_type != GL_UNSIGNED_NORMALIZED || green_type != GL_UNSIGNED_NORMALIZED || + blue_type != GL_UNSIGNED_NORMALIZED || alpha_type != GL_UNSIGNED_NORMALIZED) + { + return fail(prefix + "component_type"); + } + } + GLint extra_width = 0; + glGetTexLevelParameteriv(GL_TEXTURE_2D, static_cast(levels), GL_TEXTURE_WIDTH, &extra_width); + if (extra_width != 0) return fail("extra_level"); + if (!noGlError()) return fail("gl_error"); + return true; +} + +bool matchesOutput(LLRenderTarget& output, std::string* error = nullptr) +{ + const auto fail = [error](const char* reason) + { + if (error) + { + *error = reason; + } + return false; + }; + if (!output.isComplete() || output.isBoundInStack() || output.getWidth() != TEXTURE_UPLOAD_OUTPUT_WIDTH || + output.getHeight() != TEXTURE_UPLOAD_OUTPUT_HEIGHT || output.getUsage() != LLTexUnit::TT_TEXTURE || + output.getNumTextures() != 1 || (output.getColorFormat() != GL_RGBA && output.getColorFormat() != GL_RGBA8) || + output.getDepth() != 0 || output.getTexture() == 0) + { + return fail("metadata"); + } + std::string texture_error; + if (!matchesTextureLevels(output.getTexture(), TEXTURE_UPLOAD_OUTPUT_WIDTH, TEXTURE_UPLOAD_OUTPUT_HEIGHT, 1, + false, &texture_error, GL_RGBA)) + { + if (error) + { + *error = "texture_" + texture_error; + } + return false; + } + + output.bindTarget(); + const GLenum status = glCheckFramebufferStatus(GL_FRAMEBUFFER); + GLint object_type = 0; + GLint object_name = 0; + GLint component_type = 0; + GLint color_encoding = 0; + glGetFramebufferAttachmentParameteriv(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE, &object_type); + glGetFramebufferAttachmentParameteriv(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_FRAMEBUFFER_ATTACHMENT_OBJECT_NAME, &object_name); + glGetFramebufferAttachmentParameteriv(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_FRAMEBUFFER_ATTACHMENT_COMPONENT_TYPE, &component_type); + glGetFramebufferAttachmentParameteriv(GL_FRAMEBUFFER, GL_COLOR_ATTACHMENT0, + GL_FRAMEBUFFER_ATTACHMENT_COLOR_ENCODING, &color_encoding); + const bool valid = status == GL_FRAMEBUFFER_COMPLETE && object_type == GL_TEXTURE && + object_name == static_cast(output.getTexture()) && component_type == GL_UNSIGNED_NORMALIZED && + color_encoding == GL_LINEAR && noGlError(); + output.flush(); + if (!valid) return fail("framebuffer"); + if (!noGlError()) return fail("flush"); + return true; +} + +bool matchesSampler(const Sampler& sampler) +{ + if (!glIsSampler(sampler.mName)) + { + return false; + } + GLint min_filter = 0; + GLint mag_filter = 0; + GLint wrap_s = 0; + GLint wrap_t = 0; + GLint compare_mode = 0; + GLfloat min_lod = 0.f; + GLfloat max_lod = 0.f; + GLfloat lod_bias = 0.f; + glGetSamplerParameteriv(sampler.mName, GL_TEXTURE_MIN_FILTER, &min_filter); + glGetSamplerParameteriv(sampler.mName, GL_TEXTURE_MAG_FILTER, &mag_filter); + glGetSamplerParameteriv(sampler.mName, GL_TEXTURE_WRAP_S, &wrap_s); + glGetSamplerParameteriv(sampler.mName, GL_TEXTURE_WRAP_T, &wrap_t); + glGetSamplerParameteriv(sampler.mName, GL_TEXTURE_COMPARE_MODE, &compare_mode); + glGetSamplerParameterfv(sampler.mName, GL_TEXTURE_MIN_LOD, &min_lod); + glGetSamplerParameterfv(sampler.mName, GL_TEXTURE_MAX_LOD, &max_lod); + glGetSamplerParameterfv(sampler.mName, GL_TEXTURE_LOD_BIAS, &lod_bias); + bool valid = min_filter == GL_LINEAR_MIPMAP_LINEAR && mag_filter == GL_LINEAR && wrap_s == GL_CLAMP_TO_EDGE && + wrap_t == GL_CLAMP_TO_EDGE && compare_mode == GL_NONE && min_lod == -1000.f && max_lod == 1000.f && lod_bias == 0.f; + if (gGLManager.mHasAnisotropic) + { + GLfloat anisotropy = 0.f; + glGetSamplerParameterfv(sampler.mName, GL_TEXTURE_MAX_ANISOTROPY, &anisotropy); + valid = valid && anisotropy == 1.f; + } + return valid && noGlError(); +} + +bool matchesScreenTriangle(LLVertexBuffer& buffer, LLGLSLShader& shader, std::string* error = nullptr) +{ + const auto fail = [error](std::string reason) + { + if (error) + { + *error = std::move(reason); + } + return false; + }; + if (buffer.getNumVerts() != 3 || buffer.getNumIndices() != 0 || buffer.getTypeMask() != SCREEN_VERTEX_MASK || + buffer.getSize() != 48 || buffer.getIndicesSize() != 0 || buffer.getOffset(LLVertexBuffer::TYPE_VERTEX) != 0) + { + return fail("metadata"); + } + + bindPreservingUniformDirty(shader); + buffer.setBuffer(); + GLint vertex_name = 0; + GLint vertex_size = 0; + glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &vertex_name); + if (vertex_name == 0 || vertex_name != static_cast(LLVertexBuffer::sGLRenderBuffer) || + !glIsBuffer(static_cast(vertex_name))) + { + return fail("buffer_identity"); + } + glGetBufferParameteriv(GL_ARRAY_BUFFER, GL_BUFFER_SIZE, &vertex_size); + U32 expected_allocation = buffer.getSize(); +#if LL_DARWIN || LL_ARM64 + if (!gGLManager.mIsApple) +#endif + { + U32 next_power_of_two = 1; + while (next_power_of_two < expected_allocation) + { + next_power_of_two *= 2; + } + const U32 block_size = std::max(next_power_of_two / 8, 16U); + expected_allocation += block_size - (expected_allocation % block_size); + } + if (vertex_size != static_cast(expected_allocation)) + { + return fail("allocation_" + std::to_string(vertex_size)); + } + if (!noGlError()) + { + return fail("gl_error"); + } + return true; +} + +std::optional prepare(const FrameSnapshot& frame, Registry& registry) +{ + auto inputs = decodeStreamingUploadFrame(frame); + if (!inputs) + { + return std::nullopt; + } + if (inputs->mFrame != TEXTURE_UPLOAD_DIAGNOSTIC_FRAME || inputs->mHandles != StreamingUploadHandles{}) + { + return std::nullopt; + } + Prepared result; + result.mInputs = std::move(*inputs); + result.mScreenTriangle = registry.resolve(result.mInputs.mHandles.mScreenTriangle); + result.mOldImage = registry.resolveRegisteredImage(result.mInputs.mHandles.mOldImage); + result.mReplacementImage = registry.resolveRegisteredImage(result.mInputs.mHandles.mReplacementImage); + result.mOutput = registry.resolveOutput(result.mInputs.mHandles.mOutput); + result.mSampler = registry.resolve(result.mInputs.mHandles.mSampler); + result.mShader = registry.resolve(result.mInputs.mHandles.mPipeline, frame.mPipelines.front().mProgram); + result.mLifecycle = registry.lifecycle(); + + if (!result.mScreenTriangle || !result.mOldImage || !result.mReplacementImage || !result.mOutput || !result.mSampler || + !result.mShader || !result.mLifecycle || result.mOldImage == result.mReplacementImage || + result.mLifecycle->mCurrentImage != result.mInputs.mHandles.mOldImage || + result.mLifecycle->mLastRevision != TEXTURE_UPLOAD_PRIOR_REVISION || + result.mInputs.mRevision <= result.mLifecycle->mLastRevision || result.mLifecycle->mCompletionPending || + result.mLifecycle->mCompletionCount != 0 || result.mLifecycle->mCompletedDestination || + result.mLifecycle->mCompletedRevision != 0 || result.mLifecycle->mCompletedFrame != 0 || + result.mLifecycle->mRetirementCount != 0 || result.mLifecycle->mRetiredResource || + result.mLifecycle->mRetirementFrame != 0 || + !registry.isResolvable(result.mInputs.mHandles.mOldImage) || + registry.isResolvable(result.mInputs.mHandles.mReplacementImage) || + !matchesImageMetadata(*result.mOldImage) || !matchesImageMetadata(*result.mReplacementImage) || + !result.mOutput->isComplete()) + { + return std::nullopt; + } + return result; +} + +bool livePreflight(const Prepared& prepared, std::string* error) +{ + const auto fail = [error](const char* reason) + { + if (error) + { + *error = reason; + } + return false; + }; + const GLuint old_name = prepared.mOldImage->getTexName(); + const GLuint replacement_name = prepared.mReplacementImage->getTexName(); + const GLuint output_name = prepared.mOutput->getTexture(); + if (old_name == replacement_name || old_name == output_name || replacement_name == output_name) + { + return fail("resource_alias"); + } + std::string texture_error; + if (!matchesTextureLevels(old_name, TEXTURE_UPLOAD_RESIDENT_WIDTH, TEXTURE_UPLOAD_RESIDENT_HEIGHT, + TEXTURE_UPLOAD_MIP_LEVELS, true, &texture_error)) + { + if (error) *error = "old_image_" + texture_error; + return false; + } + if (!matchesTextureLevels(replacement_name, TEXTURE_UPLOAD_RESIDENT_WIDTH, TEXTURE_UPLOAD_RESIDENT_HEIGHT, + TEXTURE_UPLOAD_MIP_LEVELS, true, &texture_error)) + { + if (error) *error = "replacement_image_" + texture_error; + return false; + } + std::string output_error; + if (!matchesOutput(*prepared.mOutput, &output_error)) + { + if (error) + { + *error = "output_" + output_error; + } + return false; + } + if (!matchesSampler(*prepared.mSampler)) return fail("sampler"); + if (!matchesShader(*prepared.mShader)) return fail("shader"); + std::string screen_error; + if (!matchesScreenTriangle(*prepared.mScreenTriangle, *prepared.mShader, &screen_error)) + { + if (error) *error = "screen_triangle_" + screen_error; + return false; + } + if (!noGlError()) return fail("gl_error"); + return true; +} + +LLPointer normalizeSource(const StreamingUploadInputs& inputs) +{ + LLPointer raw = new LLImageRaw(TEXTURE_UPLOAD_RESIDENT_WIDTH, TEXTURE_UPLOAD_RESIDENT_HEIGHT, + TEXTURE_UPLOAD_CHANNELS); + if (!raw || raw->isBufferInvalid()) + { + return nullptr; + } + + constexpr std::size_t tight_row = static_cast(TEXTURE_UPLOAD_RESIDENT_WIDTH) * TEXTURE_UPLOAD_CHANNELS; + for (std::size_t source_y = 0; source_y < TEXTURE_UPLOAD_RESIDENT_HEIGHT; ++source_y) + { + const std::size_t destination_y = TEXTURE_UPLOAD_RESIDENT_HEIGHT - 1 - source_y; + std::memcpy(raw->getData() + destination_y * tight_row, + inputs.mPixels.data() + source_y * TEXTURE_UPLOAD_ROW_PITCH, tight_row); + } + return raw; +} + +void configureDrawState() +{ + constexpr std::array disabled{ GL_BLEND, + GL_CULL_FACE, + GL_DEPTH_TEST, + GL_STENCIL_TEST, + GL_SCISSOR_TEST, + GL_DITHER, + GL_FRAMEBUFFER_SRGB, + GL_RASTERIZER_DISCARD, + GL_MULTISAMPLE, + GL_SAMPLE_ALPHA_TO_COVERAGE, + GL_SAMPLE_ALPHA_TO_ONE, + GL_SAMPLE_COVERAGE, + GL_SAMPLE_MASK, + GL_SAMPLE_SHADING }; + for (GLenum capability : disabled) + { + glDisable(capability); + } + glDisable(GL_COLOR_LOGIC_OP); + GLint clip_distance_count = 0; + glGetIntegerv(GL_MAX_CLIP_DISTANCES, &clip_distance_count); + for (GLint index = 0; index < clip_distance_count; ++index) + { + glDisable(static_cast(GL_CLIP_DISTANCE0 + index)); + } + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); +#if !LL_DARWIN + if (gGLManager.mGLVersion >= 4.49f && glClipControl) + { + glClipControl(GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE); + } +#endif + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + glViewport(0, 0, TEXTURE_UPLOAD_OUTPUT_WIDTH, TEXTURE_UPLOAD_OUTPUT_HEIGHT); +} + +class UniformRestore +{ +public: + UniformRestore(GLuint program, GLint location) + : mProgram(program), mLocation(location) + { + glGetUniformiv(mProgram, mLocation, &mValue); + } + + ~UniformRestore() + { + GLint current = 0; + glGetIntegerv(GL_CURRENT_PROGRAM, ¤t); + glUseProgram(mProgram); + glUniform1i(mLocation, mValue); + glUseProgram(static_cast(current)); + } + +private: + GLuint mProgram = 0; + GLint mLocation = -1; + GLint mValue = 0; +}; + +bool drawSample(const Prepared& prepared) +{ + LLRenderTarget& output = *prepared.mOutput; + output.bindTarget(); + configureDrawState(); + + bindPreservingUniformDirty(*prepared.mShader); + const GLint diffuse_location = glGetUniformLocation(prepared.mShader->mProgramObject, "diffuseMap"); + if (diffuse_location < 0) + { + output.flush(); + return false; + } + UniformRestore uniform_restore(prepared.mShader->mProgramObject, diffuse_location); + glUniform1i(diffuse_location, 0); + if (!gGL.getTexUnit(0)->bind(prepared.mReplacementImage, false, true)) + { + output.flush(); + return false; + } + glBindSampler(0, prepared.mSampler->mName); + prepared.mScreenTriangle->setBuffer(); + prepared.mScreenTriangle->drawArrays(LLRender::TRIANGLES, 0, 3); + const bool success = noGlError(); + output.flush(); + return success && noGlError(); +} + +bool waitForCompletion() +{ + GLsync sync = glFenceSync(GL_SYNC_GPU_COMMANDS_COMPLETE, 0); + if (!sync) + { + noGlError(); + return false; + } + glFlush(); + const GLenum status = glClientWaitSync(sync, GL_SYNC_FLUSH_COMMANDS_BIT, COMPLETION_TIMEOUT_NS); + glDeleteSync(sync); + return (status == GL_ALREADY_SIGNALED || status == GL_CONDITION_SATISFIED) && noGlError(); +} + +bool readMipmaps(const LLImageGL& image, ExecutionResult& result) +{ + glActiveTexture(GL_TEXTURE0); + glBindTexture(GL_TEXTURE_2D, image.getTexName()); + for (std::uint32_t level = 0; level < TEXTURE_UPLOAD_MIP_LEVELS; ++level) + { + result.mMipRGBA8[level].resize(TEXTURE_UPLOAD_MIP_BYTE_SIZES[level]); + glGetTexImage(GL_TEXTURE_2D, static_cast(level), GL_RGBA, GL_UNSIGNED_BYTE, + result.mMipRGBA8[level].data()); + } + return noGlError(); +} + +bool readOutput(LLRenderTarget& output, ExecutionResult& result) +{ + result.mSampledRGBA8.resize(TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT); + output.bindTarget(); + glReadBuffer(GL_COLOR_ATTACHMENT0); + glReadPixels(0, 0, TEXTURE_UPLOAD_OUTPUT_WIDTH, TEXTURE_UPLOAD_OUTPUT_HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE, + result.mSampledRGBA8.data()); + const bool success = noGlError(); + output.flush(); + return success && noGlError(); +} + +bool hasMultipleChangedTexels(const std::vector& before, const std::vector& after) +{ + if (before.size() != after.size() || after.size() < TEXTURE_UPLOAD_CHANNELS * 2 || + after.size() % TEXTURE_UPLOAD_CHANNELS != 0) + { + return false; + } + std::optional first_changed; + for (std::size_t offset = 0; offset < after.size(); offset += TEXTURE_UPLOAD_CHANNELS) + { + const auto before_texel = std::next(before.begin(), static_cast(offset)); + const auto after_texel = std::next(after.begin(), static_cast(offset)); + if (!std::equal(after_texel, std::next(after_texel, TEXTURE_UPLOAD_CHANNELS), before_texel)) + { + if (!first_changed) + { + first_changed = offset; + } + else + { + const auto first_texel = std::next(after.begin(), static_cast(*first_changed)); + if (!std::equal(after_texel, std::next(after_texel, TEXTURE_UPLOAD_CHANNELS), first_texel)) + { + return true; + } + } + } + } + return false; +} + +bool verifiesUploadedContent(const ExecutionResult& before, const ExecutionResult& completed, + const LLImageRaw& normalized_source) +{ + const std::size_t source_size = static_cast(normalized_source.getWidth()) * + normalized_source.getHeight() * normalized_source.getComponents(); + if (completed.mMipRGBA8[0].size() != source_size || + !std::equal(completed.mMipRGBA8[0].begin(), completed.mMipRGBA8[0].end(), normalized_source.getData())) + { + return false; + } + for (std::size_t mip = 0; mip < TEXTURE_UPLOAD_MIP_LEVELS; ++mip) + { + if (!hasMultipleChangedTexels(before.mMipRGBA8[mip], completed.mMipRGBA8[mip])) + { + return false; + } + } + return hasMultipleChangedTexels(before.mSampledRGBA8, completed.mSampledRGBA8) && + completed.mSampledRGBA8 == completed.mMipRGBA8[1]; +} + +} // namespace + +bool execute(const LLRenderContract::FrameSnapshot& frame, Registry& registry, ExecutionResult& result, + std::string* error) +{ + if (error) + { + error->clear(); + } + const auto fail = [error](const char* reason) + { + if (error) + { + *error = reason; + } + return false; + }; + + if (!on_main_thread() || !gGLManager.mInited || gGLManager.mGLVersion < 4.09f || gGLManager.mIsDisabled || + LLGLSLShader::sProfileEnabled) + { + return fail("packet_registry_or_context_preflight"); + } + + const auto prepared = prepare(frame, registry); + if (!prepared || !noGlError()) + { + return fail("packet_registry_or_context_preflight"); + } + + GLStateRestore state; + if (!state.valid()) + { + return fail("gl_state_capture"); + } + state.isolateViewerCaches(); + if (!noGlError()) + { + return fail("gl_cache_isolation"); + } + std::string live_error; + if (!livePreflight(*prepared, &live_error)) + { + if (error) + { + *error = "live_gl_preflight " + live_error; + } + return false; + } + + // Owned normalization occurs only after every packet and live invariant has + // passed. Poison padding never reaches the production upload path. + LLPointer raw = normalizeSource(prepared->mInputs); + if (!raw) + { + return fail("source_normalization"); + } + + state.setTightPixelTransfer(); + ExecutionResult before; + if (!readMipmaps(*prepared->mReplacementImage, before) || !readOutput(*prepared->mOutput, before)) + { + return fail("pre_upload_readback"); + } + + state.setTightPixelTransfer(); + prepared->mReplacementImage->setAllowCompression(false); + prepared->mReplacementImage->setExplicitFormat(GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE, false); + const GLuint replacement_name = prepared->mReplacementImage->getTexName(); + if (!prepared->mReplacementImage->createGLTexture(static_cast(prepared->mInputs.mResidentDiscard), raw.get()) || + prepared->mReplacementImage->getTexName() != replacement_name || !noGlError() || + !matchesImageMetadata(*prepared->mReplacementImage) || + !matchesTextureLevels(replacement_name, TEXTURE_UPLOAD_RESIDENT_WIDTH, TEXTURE_UPLOAD_RESIDENT_HEIGHT, + TEXTURE_UPLOAD_MIP_LEVELS, true) || !drawSample(*prepared) || + !waitForCompletion()) + { + return fail("upload_draw_or_completion"); + } + + state.setTightPixelTransfer(); + ExecutionResult completed = makeTextureUploadArtifact(); + completed.mPriorRevision = prepared->mLifecycle->mLastRevision; + completed.mRevision = prepared->mInputs.mRevision; + completed.mCompletedDestination = prepared->mInputs.mHandles.mReplacementImage; + completed.mCompletedRevision = prepared->mInputs.mRevision; + completed.mCompletedFrame = prepared->mInputs.mFrame; + completed.mRetiredResource = prepared->mInputs.mHandles.mOldImage; + completed.mRetirementFrame = prepared->mInputs.mFrame; + completed.mOldResolvableBefore = registry.isResolvable(prepared->mInputs.mHandles.mOldImage); + + if (!readMipmaps(*prepared->mReplacementImage, completed) || !readOutput(*prepared->mOutput, completed)) + { + return fail("readback"); + } + + LifecycleLedger next = *prepared->mLifecycle; + next.mCurrentImage = prepared->mInputs.mHandles.mReplacementImage; + next.mLastRevision = prepared->mInputs.mRevision; + next.mCompletionPending = false; + ++next.mCompletionCount; + next.mCompletedDestination = prepared->mInputs.mHandles.mReplacementImage; + next.mCompletedRevision = prepared->mInputs.mRevision; + next.mCompletedFrame = prepared->mInputs.mFrame; + ++next.mRetirementCount; + next.mRetiredResource = prepared->mInputs.mHandles.mOldImage; + next.mRetirementFrame = prepared->mInputs.mFrame; + + completed.mCompletionCount = next.mCompletionCount; + completed.mRetirementCount = next.mRetirementCount; + completed.mOldResolvableAfter = false; + completed.mReplacementResolvableAfter = true; + + if (!validateTextureUploadArtifact(completed) || !verifiesUploadedContent(before, completed, *raw)) + { + return fail("artifact_or_content_validation"); + } + + if (!state.restore()) + { + return fail("gl_state_restore"); + } + + // Publication is the sole externally visible ownership mutation and occurs + // after upload, synchronization, readback, validation, and clean GL restore. + *prepared->mLifecycle = next; + result = std::move(completed); + return true; +} + +} // namespace LLRenderGLTextureUpload diff --git a/indra/llrender/llrendergltextureupload.h b/indra/llrender/llrendergltextureupload.h new file mode 100644 index 00000000000..c65ddac4958 --- /dev/null +++ b/indra/llrender/llrendergltextureupload.h @@ -0,0 +1,108 @@ +/** + * @file llrendergltextureupload.h + * @brief Narrow OpenGL registry and executor for one streamed texture upload. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERGLTEXTUREUPLOAD_H +#define LL_LLRENDERGLTEXTUREUPLOAD_H + +#include "llgltypes.h" +#include "lltextureuploaddiagnostic.h" + +#include + +class LLGLSLShader; +class LLImageGL; +class LLRenderTarget; +class LLVertexBuffer; + +namespace LLRenderGLTextureUpload +{ + +struct Sampler +{ + LLGLuint mName = 0; + LLRenderContract::Filter mMinFilter = LLRenderContract::Filter::Linear; + LLRenderContract::Filter mMagFilter = LLRenderContract::Filter::Linear; + LLRenderContract::MipFilter mMipFilter = LLRenderContract::MipFilter::Linear; + LLRenderContract::AddressMode mAddressU = LLRenderContract::AddressMode::Clamp; + LLRenderContract::AddressMode mAddressV = LLRenderContract::AddressMode::Clamp; + float mMaxAnisotropy = 1.f; + + friend constexpr bool operator==(const Sampler&, const Sampler&) = default; +}; + +// GL object destruction may happen later through LLImageGL's normal deferred +// deletion path. Publication itself uses the backend-neutral ledger. +using LifecycleLedger = LLRenderContract::StreamingUploadLifecycle; + +using ExecutionResult = LLRenderContract::TextureUploadArtifact; + +// The registry borrows all viewer and ledger objects for one synchronous +// replay. Registration and resolution never dereference a viewer pointer. +class Registry +{ +public: + bool addScreenTriangle(LLRenderContract::BufferHandle handle, LLVertexBuffer* buffer); + bool addImageGenerations(LLRenderContract::ImageHandle old_handle, LLImageGL* old_image, + LLRenderContract::ImageHandle replacement_handle, LLImageGL* replacement_image); + bool addOutput(LLRenderContract::ImageHandle handle, LLRenderTarget* output); + bool addSampler(LLRenderContract::SamplerHandle handle, Sampler sampler); + bool addPipeline(LLRenderContract::PipelineHandle handle, LLRenderContract::ShaderProgramKey program, + LLGLSLShader* shader); + bool addLifecycle(LifecycleLedger* ledger); + + LLVertexBuffer* resolve(LLRenderContract::BufferHandle handle) const; + LLImageGL* resolveRegisteredImage(LLRenderContract::ImageHandle handle) const; + LLRenderTarget* resolveOutput(LLRenderContract::ImageHandle handle) const; + const Sampler* resolve(LLRenderContract::SamplerHandle handle) const; + LLGLSLShader* resolve(LLRenderContract::PipelineHandle handle, + const LLRenderContract::ShaderProgramKey& program) const; + LifecycleLedger* lifecycle() const; + + // Logical resolution follows the publication ledger, not the lifetime of a + // deferred GL name. + bool isResolvable(LLRenderContract::ImageHandle handle) const; + +private: + LLRenderContract::BufferHandle mScreenHandle; + LLVertexBuffer* mScreenTriangle = nullptr; + + LLRenderContract::ImageHandle mOldHandle; + LLImageGL* mOldImage = nullptr; + LLRenderContract::ImageHandle mReplacementHandle; + LLImageGL* mReplacementImage = nullptr; + + LLRenderContract::ImageHandle mOutputHandle; + LLRenderTarget* mOutput = nullptr; + + LLRenderContract::SamplerHandle mSamplerHandle; + Sampler mSampler; + + LLRenderContract::PipelineHandle mPipelineHandle; + LLRenderContract::ShaderProgramKey mProgram; + LLGLSLShader* mShader = nullptr; + + LifecycleLedger* mLifecycle = nullptr; +}; + +// Decodes the strict packet and preflights every packet, registry, ledger, and +// live GL invariant before uploading. On failure the result and ledger are left +// unchanged. On success result contains raw BottomLeft RGBA8 readback. The +// optional error identifies the failed gate without exposing driver data. +bool execute(const LLRenderContract::FrameSnapshot& frame, Registry& registry, ExecutionResult& result, + std::string* error = nullptr); + +} // namespace LLRenderGLTextureUpload + +#endif // LL_LLRENDERGLTEXTUREUPLOAD_H diff --git a/indra/llrender/llrendergltonemap.cpp b/indra/llrender/llrendergltonemap.cpp new file mode 100644 index 00000000000..0df956897f3 --- /dev/null +++ b/indra/llrender/llrendergltonemap.cpp @@ -0,0 +1,235 @@ +/** + * @file llrendergltonemap.cpp + * @brief OpenGL replay of the canonical tonemap packet. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendergltonemap.h" + +#include "llgl.h" +#include "llglslshader.h" +#include "llrendertarget.h" +#include "llshadermgr.h" +#include "llvertexbuffer.h" + +#include +#include + +namespace LLRenderGLTonemap +{ +namespace +{ + +struct Prepared +{ + LLRenderContract::TonemapInputs mInputs; + LLVertexBuffer* mScreenTriangle = nullptr; + LLRenderTarget* mScene = nullptr; + LLRenderTarget* mExposure = nullptr; + LLRenderTarget* mDestination = nullptr; + LLGLSLShader* mShader = nullptr; +}; + +std::optional pixelFormat(const LLRenderTarget& target) +{ + switch (target.getColorFormat()) + { + case GL_RGBA: + case GL_RGBA8: + return LLRenderContract::PixelFormat::RGBA8Unorm; + case GL_RGBA16F: + return LLRenderContract::PixelFormat::RGBA16Float; + case GL_R16F: + return LLRenderContract::PixelFormat::R16Float; + default: + return std::nullopt; + } +} + +bool hasPermutation(const LLGLSLShader& shader, const char* name) +{ + const auto found = shader.mDefines.find(name); + return found != shader.mDefines.end() && found->second == "1"; +} + +bool matchesVariant(LLGLSLShader& shader, LLRenderContract::TonemapVariant variant) +{ + const auto bits = static_cast(variant); + const bool no_post = (bits & 1U) != 0; + const bool gamma_correct = (bits & 2U) != 0; + const bool legacy_gamma = (bits & 4U) != 0; + + if (!shader.mFeatures.hasSrgb || !shader.mFeatures.hasTonemap || + hasPermutation(shader, "NO_POST") != no_post || + hasPermutation(shader, "GAMMA_CORRECT") != gamma_correct || + hasPermutation(shader, "LEGACY_GAMMA") != legacy_gamma || + shader.getTextureChannel(LLShaderMgr::DEFERRED_DIFFUSE) < 0) + { + return false; + } + + static LLStaticHashedString exposure("exposure"); + static LLStaticHashedString tonemap_mix("tonemap_mix"); + static LLStaticHashedString tonemap_type("tonemap_type"); + if (!no_post && (shader.getTextureChannel(LLShaderMgr::EXPOSURE_MAP) < 0 || + shader.getUniformLocation(exposure) < 0 || shader.getUniformLocation(tonemap_mix) < 0 || + shader.getUniformLocation(tonemap_type) < 0)) + { + return false; + } + return !legacy_gamma || shader.getUniformLocation(LLShaderMgr::GAMMA) >= 0; +} + +std::optional prepare(const LLRenderContract::FrameSnapshot& frame, const Registry& registry) +{ + auto inputs = LLRenderContract::decodeTonemapFrame(frame); + if (!inputs) + { + return std::nullopt; + } + + Prepared result; + result.mInputs = *inputs; + result.mScreenTriangle = registry.resolve(inputs->mHandles.mScreenTriangle); + result.mScene = registry.resolve(inputs->mHandles.mScene); + result.mExposure = registry.resolve(inputs->mHandles.mExposure); + result.mDestination = registry.resolve(inputs->mHandles.mDestination); + + const LLRenderContract::PipelineResource& pipeline = frame.mPipelines.front(); + result.mShader = registry.resolve(inputs->mHandles.mPipeline, pipeline.mProgram); + const Sampler* point_sampler = registry.resolve(inputs->mHandles.mPointSampler); + const Sampler* linear_sampler = registry.resolve(inputs->mHandles.mLinearSampler); + + if (!result.mScreenTriangle || !result.mScene || !result.mExposure || !result.mDestination || !result.mShader || + !point_sampler || *point_sampler != Sampler::Point || !linear_sampler || *linear_sampler != Sampler::Linear) + { + return std::nullopt; + } + + const auto scene_format = pixelFormat(*result.mScene); + const auto exposure_format = pixelFormat(*result.mExposure); + const auto destination_format = pixelFormat(*result.mDestination); + if (!result.mScreenTriangle->hasDataType(LLVertexBuffer::TYPE_VERTEX) || result.mScreenTriangle->getNumVerts() < 3 || + result.mScreenTriangle->getSize() < 48 || !result.mShader->isComplete() || !matchesVariant(*result.mShader, inputs->mVariant) || + !result.mScene->isComplete() || !result.mExposure->isComplete() || !result.mDestination->isComplete() || + result.mDestination->isBoundInStack() || result.mScene == result.mDestination || result.mExposure == result.mDestination || + result.mScene->getWidth() != inputs->mSourceExtent.mWidth || result.mScene->getHeight() != inputs->mSourceExtent.mHeight || + result.mExposure->getWidth() != 1 || result.mExposure->getHeight() != 1 || + result.mDestination->getWidth() != inputs->mDestinationExtent.mWidth || + result.mDestination->getHeight() != inputs->mDestinationExtent.mHeight || + scene_format != LLRenderContract::PixelFormat::RGBA16Float || exposure_format != LLRenderContract::PixelFormat::R16Float || + destination_format != inputs->mDestinationFormat) + { + return std::nullopt; + } + + return result; +} + +class ColorMaskRestore +{ +public: + ColorMaskRestore() + { + glGetBooleanv(GL_COLOR_WRITEMASK, mMask); + } + + ~ColorMaskRestore() + { + gGL.setColorMask(mMask[0] == GL_TRUE, mMask[1] == GL_TRUE, mMask[2] == GL_TRUE, mMask[3] == GL_TRUE); + } + +private: + GLboolean mMask[4] = { GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE }; +}; + +class ScissorBoxRestore +{ +public: + ScissorBoxRestore() + { + glGetIntegerv(GL_SCISSOR_BOX, mBox); + } + + ~ScissorBoxRestore() + { + glScissor(mBox[0], mBox[1], mBox[2], mBox[3]); + } + +private: + GLint mBox[4] = { 0, 0, 0, 0 }; +}; + +} + +bool execute(const LLRenderContract::FrameSnapshot& frame, const Registry& registry) +{ + const auto prepared = prepare(frame, registry); + if (!prepared) + { + return false; + } + + const auto& inputs = prepared->mInputs; + prepared->mDestination->bindTarget(); + { + LLGLDisable blend(GL_BLEND); + LLGLDisable cull(GL_CULL_FACE); + LLGLDepthTest depth(GL_FALSE, GL_FALSE, GL_LEQUAL); + LLGLEnable scissor(GL_SCISSOR_TEST); + ColorMaskRestore color_mask; + ScissorBoxRestore scissor_box; + + gGL.setColorMask(true, true); + glViewport(0, 0, static_cast(inputs.mDestinationExtent.mWidth), + static_cast(inputs.mDestinationExtent.mHeight)); + glScissor(0, 0, static_cast(inputs.mDestinationExtent.mWidth), + static_cast(inputs.mDestinationExtent.mHeight)); + + prepared->mShader->bind(); + const S32 scene_channel = prepared->mShader->bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, prepared->mScene, false, + LLTexUnit::TFO_POINT); + if (scene_channel >= 0) + { + gGL.getTexUnit(scene_channel)->setTextureAddressMode(LLTexUnit::TAM_MIRROR); + } + const S32 exposure_channel = prepared->mShader->bindTexture(LLShaderMgr::EXPOSURE_MAP, prepared->mExposure, false, + LLTexUnit::TFO_BILINEAR); + if (exposure_channel >= 0) + { + gGL.getTexUnit(exposure_channel)->setTextureAddressMode(LLTexUnit::TAM_MIRROR); + } + + prepared->mShader->uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, static_cast(inputs.mSourceExtent.mWidth), + static_cast(inputs.mSourceExtent.mHeight)); + prepared->mShader->uniform1f(LLShaderMgr::GAMMA, inputs.mParameters.mGamma); + + static LLStaticHashedString exposure("exposure"); + static LLStaticHashedString tonemap_mix("tonemap_mix"); + static LLStaticHashedString tonemap_type("tonemap_type"); + prepared->mShader->uniform1f(exposure, inputs.mParameters.mExposure); + prepared->mShader->uniform1f(tonemap_mix, inputs.mParameters.mTonemapMix); + prepared->mShader->uniform1i(tonemap_type, static_cast(inputs.mParameters.mTonemapType)); + + prepared->mScreenTriangle->setBuffer(); + prepared->mScreenTriangle->drawArrays(LLRender::TRIANGLES, 0, 3); + + gGL.getTexUnit(0)->unbind(prepared->mScene->getUsage()); + prepared->mShader->unbind(); + } + prepared->mDestination->flush(); + return true; +} + +} diff --git a/indra/llrender/llrendergltonemap.h b/indra/llrender/llrendergltonemap.h new file mode 100644 index 00000000000..9386a4e3127 --- /dev/null +++ b/indra/llrender/llrendergltonemap.h @@ -0,0 +1,160 @@ +/** + * @file llrendergltonemap.h + * @brief Narrow OpenGL registry and executor for tonemap packets. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERGLTONEMAP_H +#define LL_LLRENDERGLTONEMAP_H + +#include "lltonemapcontract.h" + +#include +#include +#include +#include +#include + +class LLGLSLShader; +class LLRenderTarget; +class LLVertexBuffer; + +namespace LLRenderGLTonemap +{ + +enum class Sampler +{ + Point, + Linear +}; + +// The registry borrows existing viewer objects for one synchronous replay. +class Registry +{ +public: + bool addBuffer(LLRenderContract::BufferHandle handle, LLVertexBuffer* buffer) + { + return add(mBuffers, handle, buffer); + } + + bool addImage(LLRenderContract::ImageHandle handle, LLRenderTarget* image) + { + return add(mImages, handle, image); + } + + bool addSampler(LLRenderContract::SamplerHandle handle, Sampler sampler) + { + if (!handle || hasIndex(mSamplers, handle.mIndex)) + { + return false; + } + mSamplers.push_back({ handle, sampler }); + return true; + } + + bool addPipeline(LLRenderContract::PipelineHandle handle, LLRenderContract::ShaderProgramKey program, LLGLSLShader* shader) + { + if (!handle || !shader || program.mName.empty() || hasIndex(mPipelines, handle.mIndex)) + { + return false; + } + mPipelines.push_back({ handle, std::move(program), shader }); + return true; + } + + LLVertexBuffer* resolve(LLRenderContract::BufferHandle handle) const + { + return resolveObject(mBuffers, handle); + } + + LLRenderTarget* resolve(LLRenderContract::ImageHandle handle) const + { + return resolveObject(mImages, handle); + } + + const Sampler* resolve(LLRenderContract::SamplerHandle handle) const + { + const auto found = std::find_if(mSamplers.begin(), mSamplers.end(), + [handle](const SamplerEntry& entry) { return entry.mHandle == handle; }); + return found == mSamplers.end() ? nullptr : &found->mSampler; + } + + LLGLSLShader* resolve(LLRenderContract::PipelineHandle handle, const LLRenderContract::ShaderProgramKey& program) const + { + const auto found = std::find_if(mPipelines.begin(), mPipelines.end(), + [handle, &program](const PipelineEntry& entry) + { + return entry.mHandle == handle && entry.mProgram.mName == program.mName && + entry.mProgram.mVariant == program.mVariant; + }); + return found == mPipelines.end() ? nullptr : found->mShader; + } + +private: + template + struct ObjectEntry + { + HandleType mHandle; + ObjectType* mObject = nullptr; + }; + + struct SamplerEntry + { + LLRenderContract::SamplerHandle mHandle; + Sampler mSampler = Sampler::Linear; + }; + + struct PipelineEntry + { + LLRenderContract::PipelineHandle mHandle; + LLRenderContract::ShaderProgramKey mProgram; + LLGLSLShader* mShader = nullptr; + }; + + template + static bool hasIndex(const std::vector& entries, std::uint32_t index) + { + return std::any_of(entries.begin(), entries.end(), + [index](const Entry& entry) { return entry.mHandle.mIndex == index; }); + } + + template + static bool add(std::vector>& entries, HandleType handle, ObjectType* object) + { + if (!handle || !object || hasIndex(entries, handle.mIndex)) + { + return false; + } + entries.push_back({ handle, object }); + return true; + } + + template + static ObjectType* resolveObject(const std::vector>& entries, HandleType handle) + { + const auto found = std::find_if(entries.begin(), entries.end(), + [handle](const ObjectEntry& entry) { return entry.mHandle == handle; }); + return found == entries.end() ? nullptr : found->mObject; + } + + std::vector> mBuffers; + std::vector> mImages; + std::vector mSamplers; + std::vector mPipelines; +}; + +// Performs all validation and resolution before binding an FBO or changing GL state. +bool execute(const LLRenderContract::FrameSnapshot& frame, const Registry& registry); + +} + +#endif diff --git a/indra/llrender/llrendertarget.cpp b/indra/llrender/llrendertarget.cpp index 0b0d69812f0..71042dba7ae 100644 --- a/indra/llrender/llrendertarget.cpp +++ b/indra/llrender/llrendertarget.cpp @@ -489,6 +489,15 @@ U32 LLRenderTarget::getNumTextures() const return static_cast(mTex.size()); } +U32 LLRenderTarget::getColorFormat(U32 attachment) const +{ + if (attachment >= mInternalFormat.size()) + { + return 0; + } + return mInternalFormat[attachment]; +} + void LLRenderTarget::bindTexture(U32 index, S32 channel, LLTexUnit::eTextureFilterOptions filter_options) { gGL.getTexUnit(channel)->bindManual(mUsage, getTexture(index), filter_options == LLTexUnit::TFO_TRILINEAR || filter_options == LLTexUnit::TFO_ANISOTROPIC); diff --git a/indra/llrender/llrendertarget.h b/indra/llrender/llrendertarget.h index ea4c38d483d..d393a6b6716 100644 --- a/indra/llrender/llrendertarget.h +++ b/indra/llrender/llrendertarget.h @@ -146,6 +146,7 @@ class LLRenderTarget U32 getTexture(U32 attachment = 0) const; U32 getNumTextures() const; + U32 getColorFormat(U32 attachment = 0) const; U32 getDepth(void) const { return mDepth; } @@ -191,4 +192,3 @@ class LLRenderTarget }; #endif - diff --git a/indra/llrender/llshadermanifest.cpp b/indra/llrender/llshadermanifest.cpp new file mode 100644 index 00000000000..f1073d5cefe --- /dev/null +++ b/indra/llrender/llshadermanifest.cpp @@ -0,0 +1,641 @@ +/** + * @file llshadermanifest.cpp + * @brief Canonical legacy material shader recipes and validation. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llshadermanifest.h" + +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + + constexpr ShaderStageVisibility VERTEX_STAGE{ true, false }; + constexpr ShaderStageVisibility FRAGMENT_STAGE{ false, true }; + constexpr ShaderStageVisibility BOTH_STAGES{ true, true }; + + bool valid(ShaderBackend value) noexcept + { + switch (value) + { + case ShaderBackend::OpenGL: + case ShaderBackend::Vulkan: + return true; + } + return false; + } + + bool valid(ShaderStage value) noexcept + { + switch (value) + { + case ShaderStage::Vertex: + case ShaderStage::Fragment: + return true; + } + return false; + } + + bool valid(ShaderSourceRole value) noexcept + { + switch (value) + { + case ShaderSourceRole::Primary: + case ShaderSourceRole::Feature: + case ShaderSourceRole::Wrapper: + case ShaderSourceRole::SharedInclude: + return true; + } + return false; + } + + bool valid(ShaderValueType value) noexcept + { + switch (value) + { + case ShaderValueType::Float: + case ShaderValueType::Float2: + case ShaderValueType::Float3: + case ShaderValueType::Float4: + return true; + } + return false; + } + + bool valid(ShaderInterpolation value) noexcept + { + switch (value) + { + case ShaderInterpolation::Smooth: + case ShaderInterpolation::Flat: + return true; + } + return false; + } + + bool valid(SampledImageRole value) noexcept + { + switch (value) + { + case SampledImageRole::Diffuse: + case SampledImageRole::Normal: + case SampledImageRole::Specular: + return true; + } + return false; + } + + bool valid(ShaderImageDimension value) noexcept + { + return value == ShaderImageDimension::TwoD; + } + + bool valid(ShaderParameterOrigin value) noexcept + { + switch (value) + { + case ShaderParameterOrigin::CopiedDraw: + case ShaderParameterOrigin::DrawAndFrameDerived: + case ShaderParameterOrigin::FrameOrPass: + case ShaderParameterOrigin::FixedDefault: + return true; + } + return false; + } + + bool valid(ShaderFragmentOutputRole value) noexcept + { + switch (value) + { + case ShaderFragmentOutputRole::DiffuseEmissive: + case ShaderFragmentOutputRole::SpecularGloss: + case ShaderFragmentOutputRole::NormalEnvironment: + case ShaderFragmentOutputRole::EmissiveBuffer: + return true; + } + return false; + } + + bool valid(VertexSemantic value) noexcept + { + switch (value) + { + case VertexSemantic::Position: + case VertexSemantic::Normal: + case VertexSemantic::TexCoord0: + case VertexSemantic::Color: + case VertexSemantic::Tangent: + case VertexSemantic::TexCoord1: + case VertexSemantic::TexCoord2: + return true; + } + return false; + } + + bool valid(VertexFormat value) noexcept + { + switch (value) + { + case VertexFormat::Float2: + case VertexFormat::Float3: + case VertexFormat::Float4: + case VertexFormat::UNorm8x4: + return true; + } + return false; + } + + bool active(ShaderStageVisibility visibility) noexcept + { + return visibility.mVertex || visibility.mFragment; + } + + bool overlaps(ShaderStageVisibility left, ShaderStageVisibility right) noexcept + { + return (left.mVertex && right.mVertex) || (left.mFragment && right.mFragment); + } + + std::uint32_t byteSize(VertexFormat format) noexcept + { + switch (format) + { + case VertexFormat::Float2: + return 8; + case VertexFormat::Float3: + return 12; + case VertexFormat::Float4: + return 16; + case VertexFormat::UNorm8x4: + return 4; + } + return 0; + } + + bool sameProgram(const ShaderProgramKey& left, const ShaderProgramKey& right) noexcept + { + return left.mName == right.mName && left.mVariant == right.mVariant; + } + + bool validDefines(const std::vector& defines) + { + std::set vertex_names; + std::set fragment_names; + for (const ShaderDefine& define : defines) + { + if (define.mName.empty() || define.mValue.empty() || !active(define.mVisibility) || + (define.mVisibility.mVertex && !vertex_names.insert(define.mName).second) || + (define.mVisibility.mFragment && !fragment_names.insert(define.mName).second)) + { + return false; + } + } + return true; + } + + bool validVertexInputs(const std::vector& inputs) + { + std::set names; + std::set semantics; + std::set locations; + for (const ShaderVertexInput& input : inputs) + { + if (input.mName.empty() || !valid(input.mSemantic) || !valid(input.mFormat) || input.mStride < byteSize(input.mFormat) || + !names.insert(input.mName).second || !semantics.insert(input.mSemantic).second || !locations.insert(input.mLocation).second) + { + return false; + } + } + return !inputs.empty(); + } + + bool validInterstage(const std::vector& variables) + { + std::set names; + std::set locations; + for (const ShaderInterstageVariable& variable : variables) + { + if (variable.mName.empty() || !valid(variable.mType) || !valid(variable.mInterpolation) || + !names.insert(variable.mName).second || !locations.insert(variable.mLocation).second) + { + return false; + } + } + return true; + } + + bool validSampledImages(const std::vector& images) + { + std::set names; + std::set roles; + std::set> bindings; + for (const ShaderSampledImage& image : images) + { + if (image.mName.empty() || !valid(image.mRole) || !valid(image.mDimension) || !active(image.mVisibility) || + !names.insert(image.mName).second || !roles.insert(image.mRole).second || + !bindings.emplace(image.mSet, image.mBinding).second) + { + return false; + } + } + return !images.empty(); + } + + bool validParameters(const std::vector& parameters) + { + std::set names; + std::set shader_names; + std::uint32_t next_offset = 0; + for (const ShaderLogicalParameter& parameter : parameters) + { + const bool fixed = parameter.mOrigin == ShaderParameterOrigin::FixedDefault; + if (parameter.mName.empty() || parameter.mShaderName.empty() || parameter.mWordCount == 0 || + parameter.mWordOffset != next_offset || !valid(parameter.mOrigin) || !active(parameter.mVisibility) || + !names.insert(parameter.mName).second || !shader_names.insert(parameter.mShaderName).second || + fixed != parameter.mFixedValue.has_value() || (parameter.mFixedValue && !std::isfinite(*parameter.mFixedValue)) || + parameter.mWordOffset > std::numeric_limits::max() - parameter.mWordCount) + { + return false; + } + next_offset += parameter.mWordCount; + } + return next_offset != 0; + } + + bool validOutputs(const ShaderManifest& manifest) + { + constexpr std::uint32_t MAX_INTERFACE_LOCATIONS = 32; + std::set roles; + std::set logical_locations; + for (const ShaderLogicalFragmentOutput& output : manifest.mLogicalFragmentOutputs) + { + if (!valid(output.mRole) || !valid(output.mType) || !roles.insert(output.mRole).second || + !logical_locations.insert(output.mLocation).second) + { + return false; + } + } + if (manifest.mLogicalFragmentOutputs.empty()) + { + return false; + } + + std::set names; + std::set declared_locations; + std::uint32_t logical_declarations = 0; + for (const ShaderFragmentOutputDeclaration& declaration : manifest.mFragmentOutputDeclarations) + { + if (declaration.mName.empty() || declaration.mElementCount == 0 || declaration.mElementCount > MAX_INTERFACE_LOCATIONS || + !valid(declaration.mElementType) || declaration.mLogicalElementCount > declaration.mElementCount || + declaration.mFirstLocation > std::numeric_limits::max() - declaration.mElementCount || + !names.insert(declaration.mName).second || + (declaration.mElementCount != declaration.mLogicalElementCount) != declaration.mExtraElementsInert) + { + return false; + } + for (std::uint32_t index = 0; index < declaration.mElementCount; ++index) + { + if (!declared_locations.insert(declaration.mFirstLocation + index).second) + { + return false; + } + } + logical_declarations += declaration.mLogicalElementCount; + } + if (logical_declarations != manifest.mLogicalFragmentOutputs.size()) + { + return false; + } + return std::all_of(manifest.mLogicalFragmentOutputs.begin(), manifest.mLogicalFragmentOutputs.end(), + [&manifest](const ShaderLogicalFragmentOutput& output) + { + return std::any_of(manifest.mFragmentOutputDeclarations.begin(), manifest.mFragmentOutputDeclarations.end(), + [&output](const ShaderFragmentOutputDeclaration& declaration) + { + return output.mType == declaration.mElementType && + output.mLocation >= declaration.mFirstLocation && + output.mLocation < + declaration.mFirstLocation + declaration.mLogicalElementCount; + }); + }); + } + + bool validBaggage(const std::vector& blocks) + { + std::set block_names; + std::set bindings; + for (const ShaderLinkedBlockBaggage& block : blocks) + { + std::set members; + if (block.mName.empty() || block.mByteSize == 0 || !active(block.mVisibility) || !block_names.insert(block.mName).second || + !bindings.insert(block.mBinding).second || block.mActiveMembers.empty() || + std::any_of(block.mActiveMembers.begin(), block.mActiveMembers.end(), + [&members](const std::string& member) { return member.empty() || !members.insert(member).second; })) + { + return false; + } + } + return true; + } + + bool validPushConstants(const std::vector& ranges) noexcept + { + std::uint32_t last_end = 0; + for (const ShaderPushConstantRange& range : ranges) + { + if (range.mByteSize == 0 || !active(range.mVisibility) || range.mByteOffset < last_end || + range.mByteOffset > std::numeric_limits::max() - range.mByteSize) + { + return false; + } + last_end = range.mByteOffset + range.mByteSize; + } + return true; + } + + ShaderManifest commonManifest(ShaderBackend backend) + { + ShaderManifest manifest; + manifest.mProgram = legacyNormSpecDiagnosticProgramKey(); + manifest.mBackend = backend; + manifest.mEntryPoints = { { ShaderStage::Vertex, "main" }, { ShaderStage::Fragment, "main" } }; + manifest.mSampledImages = { { "diffuseMap", SampledImageRole::Diffuse, ShaderImageDimension::TwoD, 0, 0, FRAGMENT_STAGE }, + { "bumpMap", SampledImageRole::Normal, ShaderImageDimension::TwoD, 0, 1, FRAGMENT_STAGE }, + { "specularMap", SampledImageRole::Specular, ShaderImageDimension::TwoD, 0, 2, FRAGMENT_STAGE } }; + manifest.mLogicalParameters = { + { "modelview", "modelview_matrix", 0, 16, ShaderParameterOrigin::DrawAndFrameDerived, VERTEX_STAGE, std::nullopt }, + { "modelview_projection", "modelview_projection_matrix", 16, 16, ShaderParameterOrigin::DrawAndFrameDerived, VERTEX_STAGE, + std::nullopt }, + { "normal", "normal_matrix", 32, 9, ShaderParameterOrigin::DrawAndFrameDerived, VERTEX_STAGE, std::nullopt }, + { "diffuse_texture", "texture_matrix0", 41, 16, ShaderParameterOrigin::CopiedDraw, VERTEX_STAGE, std::nullopt }, + { "specular", "specular_color", 57, 4, ShaderParameterOrigin::CopiedDraw, FRAGMENT_STAGE, std::nullopt }, + { "clip", "clipPlane", 61, 4, ShaderParameterOrigin::FrameOrPass, FRAGMENT_STAGE, std::nullopt }, + { "environment", "env_intensity", 65, 1, ShaderParameterOrigin::CopiedDraw, FRAGMENT_STAGE, std::nullopt }, + { "emissive", "emissive_brightness", 66, 1, ShaderParameterOrigin::CopiedDraw, FRAGMENT_STAGE, std::nullopt }, + { "mirror", "mirror_flag", 67, 1, ShaderParameterOrigin::FixedDefault, FRAGMENT_STAGE, 0.f } + }; + manifest.mLogicalFragmentOutputs = { { ShaderFragmentOutputRole::DiffuseEmissive, 0, ShaderValueType::Float4 }, + { ShaderFragmentOutputRole::SpecularGloss, 1, ShaderValueType::Float4 }, + { ShaderFragmentOutputRole::NormalEnvironment, 2, ShaderValueType::Float4 } }; + return manifest; + } + + ShaderManifest openGLManifest() + { + ShaderManifest manifest = commonManifest(ShaderBackend::OpenGL); + manifest.mSourceUnits = { { ShaderStage::Vertex, "deferred/materialV.glsl", ShaderSourceRole::Primary, 1 }, + { ShaderStage::Fragment, "deferred/materialF.glsl", ShaderSourceRole::Primary, 3 }, + { ShaderStage::Vertex, "windlight/atmosphericsVarsV.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Vertex, "windlight/atmosphericsHelpersV.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Vertex, "environment/srgbF.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Vertex, "windlight/atmosphericsFuncs.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Vertex, "windlight/atmosphericsV.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Vertex, "deferred/textureUtilV.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Fragment, "deferred/globalF.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Fragment, "environment/srgbF.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Fragment, "windlight/atmosphericsVarsF.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Fragment, "windlight/atmosphericsHelpersF.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Fragment, "deferred/deferredUtil.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Fragment, "deferred/screenSpaceReflUtil.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Fragment, "deferred/reflectionProbeF.glsl", ShaderSourceRole::Feature, 3 }, + { ShaderStage::Fragment, "windlight/gammaF.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Fragment, "windlight/atmosphericsFuncs.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Fragment, "windlight/atmosphericsF.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Fragment, "environment/waterFogF.glsl", ShaderSourceRole::Feature, 1 }, + { ShaderStage::Vertex, "objects/nonindexedTextureV.glsl", ShaderSourceRole::Feature, 1 } }; + manifest.mDefines = { { "DIFFUSE_ALPHA_MODE", "0", BOTH_STAGES }, + { "HAS_NORMAL_MAP", "1", BOTH_STAGES }, + { "HAS_SPECULAR_MAP", "1", BOTH_STAGES } }; + manifest.mVertexInputs = { { "position", VertexSemantic::Position, VertexFormat::Float3, 0, 0, 16 }, + { "normal", VertexSemantic::Normal, VertexFormat::Float3, 1, 0, 16 }, + { "texcoord0", VertexSemantic::TexCoord0, VertexFormat::Float2, 2, 0, 8 }, + { "diffuse_color", VertexSemantic::Color, VertexFormat::UNorm8x4, 6, 0, 4 }, + { "tangent", VertexSemantic::Tangent, VertexFormat::Float4, 8, 0, 16 }, + { "texcoord1", VertexSemantic::TexCoord1, VertexFormat::Float2, 3, 0, 8 }, + { "texcoord2", VertexSemantic::TexCoord2, VertexFormat::Float2, 4, 0, 8 } }; + manifest.mFragmentOutputDeclarations = { { "frag_data", 0, 4, ShaderValueType::Float4, 3, true } }; + manifest.mLinkedBlockBaggage = { { "ReflectionProbes", + 0, + 49248, + FRAGMENT_STAGE, + { "refBox", "heroBox", "refSphere", "refParams", "heroSphere", "refIndex", "refNeighbor", + "refBucket", "refmapCount", "heroShape", "heroMipCount", "heroProbeCount" } } }; + return manifest; + } + + ShaderManifest vulkanManifest() + { + ShaderManifest manifest = commonManifest(ShaderBackend::Vulkan); + manifest.mSourceUnits = { + { ShaderStage::Vertex, "indra/llrender/vulkan/shaders/material.vert.glsl", ShaderSourceRole::Wrapper, std::nullopt }, + { ShaderStage::Vertex, "indra/newview/app_settings/shaders/class1/deferred/materialV.glsl", ShaderSourceRole::SharedInclude, + 1 }, + { ShaderStage::Fragment, "indra/llrender/vulkan/shaders/material.frag.glsl", ShaderSourceRole::Wrapper, std::nullopt }, + { ShaderStage::Fragment, "indra/newview/app_settings/shaders/class1/deferred/globalF.glsl", ShaderSourceRole::SharedInclude, + 1 }, + { ShaderStage::Fragment, "indra/newview/app_settings/shaders/class3/deferred/materialF.glsl", ShaderSourceRole::SharedInclude, + 3 } + }; + manifest.mDefines = { { "LL_VULKAN_SHADER", "1", VERTEX_STAGE }, + { "DIFFUSE_ALPHA_MODE", "0", VERTEX_STAGE }, + { "HAS_NORMAL_MAP", "1", VERTEX_STAGE }, + { "HAS_SPECULAR_MAP", "1", VERTEX_STAGE }, + { "LL_VULKAN_SHADER", "1", FRAGMENT_STAGE }, + { "DIFFUSE_ALPHA_MODE", "0", FRAGMENT_STAGE }, + { "HAS_NORMAL_MAP", "1", FRAGMENT_STAGE }, + { "HAS_SPECULAR_MAP", "1", FRAGMENT_STAGE }, + { "GBUFFER_FLAG_HAS_ATMOS", "0.34", FRAGMENT_STAGE } }; + manifest.mVertexInputs = { { "position", VertexSemantic::Position, VertexFormat::Float3, 0, 0, 16 }, + { "normal", VertexSemantic::Normal, VertexFormat::Float3, 1, 1, 16 }, + { "texcoord0", VertexSemantic::TexCoord0, VertexFormat::Float2, 2, 2, 8 }, + { "diffuse_color", VertexSemantic::Color, VertexFormat::UNorm8x4, 3, 3, 4 }, + { "tangent", VertexSemantic::Tangent, VertexFormat::Float4, 4, 4, 16 }, + { "texcoord1", VertexSemantic::TexCoord1, VertexFormat::Float2, 5, 5, 8 }, + { "texcoord2", VertexSemantic::TexCoord2, VertexFormat::Float2, 6, 6, 8 } }; + manifest.mInterstageVariables = { { "vary_position", 0, ShaderValueType::Float3, ShaderInterpolation::Smooth }, + { "vary_tangent", 1, ShaderValueType::Float3, ShaderInterpolation::Smooth }, + { "vary_sign", 2, ShaderValueType::Float, ShaderInterpolation::Flat }, + { "vary_normal", 3, ShaderValueType::Float3, ShaderInterpolation::Smooth }, + { "vary_texcoord1", 4, ShaderValueType::Float2, ShaderInterpolation::Smooth }, + { "vary_texcoord2", 5, ShaderValueType::Float2, ShaderInterpolation::Smooth }, + { "vertex_color", 6, ShaderValueType::Float4, ShaderInterpolation::Smooth }, + { "vary_texcoord0", 7, ShaderValueType::Float2, ShaderInterpolation::Smooth } }; + for (ShaderSampledImage& image : manifest.mSampledImages) + { + image.mSet = 1; + } + manifest.mParameterBlock = ShaderParameterBlock{ "MaterialParameterPacket", 0, 0, 272, BOTH_STAGES }; + manifest.mFragmentOutputDeclarations = { { "frag_data", 0, 3, ShaderValueType::Float4, 3, false } }; + return manifest; + } + + ShaderManifest productionVulkanManifest() + { + ShaderManifest manifest = vulkanManifest(); + manifest.mProgram = legacyNormSpecProductionProgramKey(); + manifest.mDefines = { { "LL_VULKAN_MATERIAL_PRODUCTION", "1", VERTEX_STAGE }, + { "LL_VULKAN_SHADER", "1", VERTEX_STAGE }, + { "DIFFUSE_ALPHA_MODE", "0", VERTEX_STAGE }, + { "HAS_NORMAL_MAP", "1", VERTEX_STAGE }, + { "HAS_SPECULAR_MAP", "1", VERTEX_STAGE }, + { "HAS_EMISSIVE", "1", VERTEX_STAGE }, + { "HAS_SUN_SHADOW", "1", VERTEX_STAGE }, + { "SUN_SHADOW", "1", VERTEX_STAGE }, + { "SPOT_SHADOW", "1", VERTEX_STAGE }, + { "LL_VULKAN_MATERIAL_PRODUCTION", "1", FRAGMENT_STAGE }, + { "LL_VULKAN_SHADER", "1", FRAGMENT_STAGE }, + { "DIFFUSE_ALPHA_MODE", "0", FRAGMENT_STAGE }, + { "HAS_NORMAL_MAP", "1", FRAGMENT_STAGE }, + { "HAS_SPECULAR_MAP", "1", FRAGMENT_STAGE }, + { "GBUFFER_FLAG_HAS_ATMOS", "0.34", FRAGMENT_STAGE }, + { "HAS_EMISSIVE", "1", FRAGMENT_STAGE }, + { "HAS_SUN_SHADOW", "1", FRAGMENT_STAGE }, + { "SUN_SHADOW", "1", FRAGMENT_STAGE }, + { "SPOT_SHADOW", "1", FRAGMENT_STAGE } }; + manifest.mLogicalFragmentOutputs.push_back({ ShaderFragmentOutputRole::EmissiveBuffer, 3, ShaderValueType::Float4 }); + manifest.mFragmentOutputDeclarations = { { "frag_data", 0, 4, ShaderValueType::Float4, 4, false } }; + return manifest; + } + + ShaderManifest canonicalManifest(ShaderBackend backend) + { + switch (backend) + { + case ShaderBackend::OpenGL: + return openGLManifest(); + case ShaderBackend::Vulkan: + return vulkanManifest(); + } + ShaderManifest manifest; + manifest.mBackend = backend; + return manifest; + } + +} // namespace + +bool operator==(const ShaderManifest& left, const ShaderManifest& right) +{ + return sameProgram(left.mProgram, right.mProgram) && left.mBackend == right.mBackend && left.mEntryPoints == right.mEntryPoints && + left.mSourceUnits == right.mSourceUnits && left.mDefines == right.mDefines && left.mVertexInputs == right.mVertexInputs && + left.mInterstageVariables == right.mInterstageVariables && left.mSampledImages == right.mSampledImages && + left.mLogicalParameters == right.mLogicalParameters && left.mParameterBlock == right.mParameterBlock && + left.mLogicalFragmentOutputs == right.mLogicalFragmentOutputs && + left.mFragmentOutputDeclarations == right.mFragmentOutputDeclarations && left.mLinkedBlockBaggage == right.mLinkedBlockBaggage && + left.mPushConstantRanges == right.mPushConstantRanges; +} + +// Structural validation uses temporary sets. Preserve the public fail-closed +// noexcept contract if those allocations or canonical construction fail. +bool validShaderManifest(const ShaderManifest& manifest) noexcept +try +{ + if (manifest.mProgram.mName.empty() || !valid(manifest.mBackend) || manifest.mEntryPoints.size() != 2 || + manifest.mEntryPoints[0].mStage != ShaderStage::Vertex || manifest.mEntryPoints[1].mStage != ShaderStage::Fragment || + manifest.mEntryPoints[0].mName.empty() || manifest.mEntryPoints[1].mName.empty() || manifest.mSourceUnits.empty() || + !validDefines(manifest.mDefines) || !validVertexInputs(manifest.mVertexInputs) || !validInterstage(manifest.mInterstageVariables) || + !validSampledImages(manifest.mSampledImages) || !validParameters(manifest.mLogicalParameters) || !validOutputs(manifest) || + !validBaggage(manifest.mLinkedBlockBaggage) || !validPushConstants(manifest.mPushConstantRanges)) + { + return false; + } + + std::set> sources; + for (const ShaderSourceUnit& source : manifest.mSourceUnits) + { + if (!valid(source.mStage) || !valid(source.mRole) || source.mPath.empty() || (source.mShaderClass && *source.mShaderClass == 0)) + { + return false; + } + if (!sources.emplace(source.mStage, source.mPath).second) + { + return false; + } + } + for (std::size_t left = 0; left < manifest.mDefines.size(); ++left) + { + for (std::size_t right = left + 1; right < manifest.mDefines.size(); ++right) + { + if (manifest.mDefines[left].mName == manifest.mDefines[right].mName && + overlaps(manifest.mDefines[left].mVisibility, manifest.mDefines[right].mVisibility)) + { + return false; + } + } + } + if (manifest.mParameterBlock && (manifest.mParameterBlock->mName.empty() || manifest.mParameterBlock->mByteSize == 0 || + !active(manifest.mParameterBlock->mVisibility))) + { + return false; + } + if (manifest.mParameterBlock && + std::any_of(manifest.mSampledImages.begin(), manifest.mSampledImages.end(), [&manifest](const ShaderSampledImage& image) + { return image.mSet == manifest.mParameterBlock->mSet && image.mBinding == manifest.mParameterBlock->mBinding; })) + { + return false; + } + return true; +} +catch (...) +{ + return false; +} + +bool validLegacyNormSpecDiagnosticShaderManifest(const ShaderManifest& manifest) noexcept +try +{ + return validShaderManifest(manifest) && manifest == canonicalManifest(manifest.mBackend); +} +catch (...) +{ + return false; +} + +bool validLegacyNormSpecProductionShaderManifest(const ShaderManifest& manifest) noexcept +try +{ + return validShaderManifest(manifest) && manifest == productionVulkanManifest(); +} +catch (...) +{ + return false; +} + +ShaderManifest legacyNormSpecDiagnosticShaderManifest(ShaderBackend backend) +{ + return canonicalManifest(backend); +} + +std::optional legacyNormSpecShaderManifest(const LegacyNormSpecPipelineKey& pipeline_key, ShaderBackend backend) +{ + if (!valid(backend) || !validLegacyNormSpecPipelineKey(pipeline_key)) + { + return std::nullopt; + } + if (pipeline_key == legacyNormSpecDiagnosticPipelineKey()) + { + return canonicalManifest(backend); + } + if (backend == ShaderBackend::Vulkan && + (pipeline_key == legacyNormSpecModernHDRPipelineKey() || pipeline_key == legacyNormSpecCompatibilityPipelineKey())) + { + return productionVulkanManifest(); + } + return std::nullopt; +} + +} // namespace LLRenderContract diff --git a/indra/llrender/llshadermanifest.h b/indra/llrender/llshadermanifest.h new file mode 100644 index 00000000000..c967531d821 --- /dev/null +++ b/indra/llrender/llshadermanifest.h @@ -0,0 +1,255 @@ +/** + * @file llshadermanifest.h + * @brief API-neutral shader assembly and interface manifest. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLSHADERMANIFEST_H +#define LL_LLSHADERMANIFEST_H + +#include "lldrawpacketcontract.h" + +#include +#include +#include +#include + +namespace LLRenderContract +{ + +enum class ShaderBackend : std::uint8_t +{ + OpenGL, + Vulkan +}; + +enum class ShaderStage : std::uint8_t +{ + Vertex, + Fragment +}; + +struct ShaderStageVisibility +{ + bool mVertex = false; + bool mFragment = false; + + friend constexpr bool operator==(const ShaderStageVisibility&, const ShaderStageVisibility&) = default; +}; + +enum class ShaderSourceRole : std::uint8_t +{ + Primary, + Feature, + Wrapper, + SharedInclude +}; + +struct ShaderSourceUnit +{ + ShaderStage mStage = ShaderStage::Vertex; + std::string mPath; + ShaderSourceRole mRole = ShaderSourceRole::Primary; + std::optional mShaderClass; + + friend bool operator==(const ShaderSourceUnit&, const ShaderSourceUnit&) = default; +}; + +struct ShaderDefine +{ + std::string mName; + std::string mValue; + ShaderStageVisibility mVisibility; + + friend bool operator==(const ShaderDefine&, const ShaderDefine&) = default; +}; + +struct ShaderEntryPoint +{ + ShaderStage mStage = ShaderStage::Vertex; + std::string mName; + + friend bool operator==(const ShaderEntryPoint&, const ShaderEntryPoint&) = default; +}; + +enum class ShaderValueType : std::uint8_t +{ + Float, + Float2, + Float3, + Float4 +}; + +struct ShaderVertexInput +{ + std::string mName; + VertexSemantic mSemantic = VertexSemantic::Position; + VertexFormat mFormat = VertexFormat::Float3; + std::uint32_t mLocation = 0; + std::uint32_t mBinding = 0; + std::uint32_t mStride = 0; + + friend bool operator==(const ShaderVertexInput&, const ShaderVertexInput&) = default; +}; + +enum class ShaderInterpolation : std::uint8_t +{ + Smooth, + Flat +}; + +struct ShaderInterstageVariable +{ + std::string mName; + std::uint32_t mLocation = 0; + ShaderValueType mType = ShaderValueType::Float; + ShaderInterpolation mInterpolation = ShaderInterpolation::Smooth; + + friend bool operator==(const ShaderInterstageVariable&, const ShaderInterstageVariable&) = default; +}; + +enum class SampledImageRole : std::uint8_t +{ + Diffuse, + Normal, + Specular +}; + +enum class ShaderImageDimension : std::uint8_t +{ + TwoD +}; + +struct ShaderSampledImage +{ + std::string mName; + SampledImageRole mRole = SampledImageRole::Diffuse; + ShaderImageDimension mDimension = ShaderImageDimension::TwoD; + std::uint32_t mSet = 0; + std::uint32_t mBinding = 0; + ShaderStageVisibility mVisibility; + + friend bool operator==(const ShaderSampledImage&, const ShaderSampledImage&) = default; +}; + +enum class ShaderParameterOrigin : std::uint8_t +{ + CopiedDraw, + DrawAndFrameDerived, + FrameOrPass, + FixedDefault +}; + +struct ShaderLogicalParameter +{ + std::string mName; + std::string mShaderName; + std::uint32_t mWordOffset = 0; + std::uint32_t mWordCount = 0; + ShaderParameterOrigin mOrigin = ShaderParameterOrigin::CopiedDraw; + ShaderStageVisibility mVisibility; + std::optional mFixedValue; + + friend bool operator==(const ShaderLogicalParameter&, const ShaderLogicalParameter&) = default; +}; + +struct ShaderParameterBlock +{ + std::string mName; + std::uint32_t mSet = 0; + std::uint32_t mBinding = 0; + std::uint32_t mByteSize = 0; + ShaderStageVisibility mVisibility; + + friend bool operator==(const ShaderParameterBlock&, const ShaderParameterBlock&) = default; +}; + +enum class ShaderFragmentOutputRole : std::uint8_t +{ + DiffuseEmissive, + SpecularGloss, + NormalEnvironment, + EmissiveBuffer +}; + +struct ShaderLogicalFragmentOutput +{ + ShaderFragmentOutputRole mRole = ShaderFragmentOutputRole::DiffuseEmissive; + std::uint32_t mLocation = 0; + ShaderValueType mType = ShaderValueType::Float4; + + friend bool operator==(const ShaderLogicalFragmentOutput&, const ShaderLogicalFragmentOutput&) = default; +}; + +struct ShaderFragmentOutputDeclaration +{ + std::string mName; + std::uint32_t mFirstLocation = 0; + std::uint32_t mElementCount = 0; + ShaderValueType mElementType = ShaderValueType::Float4; + std::uint32_t mLogicalElementCount = 0; + bool mExtraElementsInert = false; + + friend bool operator==(const ShaderFragmentOutputDeclaration&, const ShaderFragmentOutputDeclaration&) = default; +}; + +struct ShaderLinkedBlockBaggage +{ + std::string mName; + std::uint32_t mBinding = 0; + std::uint32_t mByteSize = 0; + ShaderStageVisibility mVisibility; + std::vector mActiveMembers; + + friend bool operator==(const ShaderLinkedBlockBaggage&, const ShaderLinkedBlockBaggage&) = default; +}; + +struct ShaderPushConstantRange +{ + std::uint32_t mByteOffset = 0; + std::uint32_t mByteSize = 0; + ShaderStageVisibility mVisibility; + + friend bool operator==(const ShaderPushConstantRange&, const ShaderPushConstantRange&) = default; +}; + +struct ShaderManifest +{ + ShaderProgramKey mProgram; + ShaderBackend mBackend = ShaderBackend::OpenGL; + std::vector mEntryPoints; + std::vector mSourceUnits; + std::vector mDefines; + std::vector mVertexInputs; + std::vector mInterstageVariables; + std::vector mSampledImages; + std::vector mLogicalParameters; + std::optional mParameterBlock; + std::vector mLogicalFragmentOutputs; + std::vector mFragmentOutputDeclarations; + std::vector mLinkedBlockBaggage; + std::vector mPushConstantRanges; + + friend bool operator==(const ShaderManifest& left, const ShaderManifest& right); +}; + +bool validShaderManifest(const ShaderManifest& manifest) noexcept; +bool validLegacyNormSpecDiagnosticShaderManifest(const ShaderManifest& manifest) noexcept; +bool validLegacyNormSpecProductionShaderManifest(const ShaderManifest& manifest) noexcept; + +ShaderManifest legacyNormSpecDiagnosticShaderManifest(ShaderBackend backend); + +std::optional legacyNormSpecShaderManifest(const LegacyNormSpecPipelineKey& pipeline_key, ShaderBackend backend); + +} // namespace LLRenderContract + +#endif // LL_LLSHADERMANIFEST_H diff --git a/indra/llrender/llshadermgr.cpp b/indra/llrender/llshadermgr.cpp index 9f7c244fe98..8fffc044d5b 100644 --- a/indra/llrender/llshadermgr.cpp +++ b/indra/llrender/llshadermgr.cpp @@ -33,6 +33,11 @@ #include "llsdserialize.h" #include "hbxxh.h" +#include +#if defined(LL_RENDER_BENCHMARK) +#include +#endif + #if LL_DARWIN #include "OpenGL/OpenGL.h" #endif @@ -45,6 +50,14 @@ using std::string; LLShaderMgr * LLShaderMgr::sInstance = NULL; +namespace +{ +#if defined(LL_RENDER_BENCHMARK) +std::atomic sShaderCompileCount{0}; +std::atomic sShaderCompileTimeUS{0}; +#endif +} + LLShaderMgr::LLShaderMgr() { } @@ -65,6 +78,24 @@ LLShaderMgr * LLShaderMgr::instance() return sInstance; } +U64 LLShaderMgr::getShaderCompileCount() +{ +#if defined(LL_RENDER_BENCHMARK) + return sShaderCompileCount.load(std::memory_order_relaxed); +#else + return 0; +#endif +} + +U64 LLShaderMgr::getShaderCompileTimeUS() +{ +#if defined(LL_RENDER_BENCHMARK) + return sShaderCompileTimeUS.load(std::memory_order_relaxed); +#else + return 0; +#endif +} + bool LLShaderMgr::attachShaderFeatures(LLGLSLShader * shader) { llassert_always(shader != NULL); @@ -880,7 +911,17 @@ GLuint LLShaderMgr::loadShaderFile(const std::string& filename, S32 & shader_lev if (ret) { LL_DEBUGS("ShaderLoading") << "glShaderSource done" << U32(ret) << LL_ENDL; +#if defined(LL_RENDER_BENCHMARK) + const auto compile_start = std::chrono::steady_clock::now(); +#endif glCompileShader(ret); +#if defined(LL_RENDER_BENCHMARK) + const auto compile_end = std::chrono::steady_clock::now(); + sShaderCompileCount.fetch_add(1, std::memory_order_relaxed); + sShaderCompileTimeUS.fetch_add( + std::chrono::duration_cast(compile_end - compile_start).count(), + std::memory_order_relaxed); +#endif error = glGetError(); if (error != GL_NO_ERROR) @@ -896,7 +937,16 @@ GLuint LLShaderMgr::loadShaderFile(const std::string& filename, S32 & shader_lev //check for errors LL_DEBUGS("ShaderLoading") << "glCompileShader done" << U32(ret) << LL_ENDL; GLint success = GL_TRUE; +#if defined(LL_RENDER_BENCHMARK) + const auto status_start = std::chrono::steady_clock::now(); +#endif glGetShaderiv(ret, GL_COMPILE_STATUS, &success); +#if defined(LL_RENDER_BENCHMARK) + const auto status_end = std::chrono::steady_clock::now(); + sShaderCompileTimeUS.fetch_add( + std::chrono::duration_cast(status_end - status_start).count(), + std::memory_order_relaxed); +#endif error = glGetError(); if (error != GL_NO_ERROR || success == GL_FALSE) @@ -1615,4 +1665,3 @@ void LLShaderMgr::initAttribsAndUniforms() dupe_check.insert(mReservedUniforms[i]); } } - diff --git a/indra/llrender/llshadermgr.h b/indra/llrender/llshadermgr.h index 1b638e6e065..5e8e271e029 100644 --- a/indra/llrender/llshadermgr.h +++ b/indra/llrender/llshadermgr.h @@ -356,6 +356,8 @@ class LLShaderMgr bool linkProgramObject(GLuint obj, bool suppress_errors = false); bool validateProgramObject(GLuint obj); GLuint loadShaderFile(const std::string& filename, S32 & shader_level, GLenum type, std::map* defines = NULL, S32 texture_index_channels = -1); + static U64 getShaderCompileCount(); + static U64 getShaderCompileTimeUS(); // Implemented in the application to actually point to the shader directory. virtual std::string getShaderDirPrefix(void) = 0; // Pure Virtual diff --git a/indra/llrender/lltextureuploadcontract.cpp b/indra/llrender/lltextureuploadcontract.cpp new file mode 100644 index 00000000000..4b27584ab07 --- /dev/null +++ b/indra/llrender/lltextureuploadcontract.cpp @@ -0,0 +1,325 @@ +/** + * @file lltextureuploadcontract.cpp + * @brief Pure builder and decoder for one streamed texture replacement. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "lltextureuploadcontract.h" + +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + +constexpr const char* TEXTURE_UPLOAD_PROGRAM = "contract.sample-texture"; +constexpr const char* TEXTURE_UPLOAD_PASS = "sample streamed image"; +constexpr Extent2D RESIDENT_EXTENT{ TEXTURE_UPLOAD_RESIDENT_WIDTH, TEXTURE_UPLOAD_RESIDENT_HEIGHT }; +constexpr Extent2D LOGICAL_EXTENT{ TEXTURE_UPLOAD_LOGICAL_WIDTH, TEXTURE_UPLOAD_LOGICAL_HEIGHT }; +constexpr Extent2D OUTPUT_EXTENT{ TEXTURE_UPLOAD_OUTPUT_WIDTH, TEXTURE_UPLOAD_OUTPUT_HEIGHT }; +constexpr ImageSubresourceRange REPLACEMENT_RANGE{ 0, TEXTURE_UPLOAD_MIP_LEVELS, 0, 1 }; + +bool sameExtent(Extent2D left, Extent2D right) +{ + return left.mWidth == right.mWidth && left.mHeight == right.mHeight; +} + +bool sameOffset(Offset2D left, Offset2D right) +{ + return left.mX == right.mX && left.mY == right.mY; +} + +bool sameRange(const ImageSubresourceRange& left, const ImageSubresourceRange& right) +{ + return left.mBaseMipLevel == right.mBaseMipLevel && left.mMipLevelCount == right.mMipLevelCount && + left.mBaseArrayLayer == right.mBaseArrayLayer && left.mArrayLayerCount == right.mArrayLayerCount; +} + +bool zeroClear(const ClearColor& clear) +{ + return clear.mRed == 0.f && clear.mGreen == 0.f && clear.mBlue == 0.f && clear.mAlpha == 0.f; +} + +bool validHandles(const StreamingUploadHandles& handles) +{ + return handles.mScreenTriangle && handles.mOldImage && handles.mReplacementImage && handles.mOutput && handles.mSampler && + handles.mPipeline && handles.mPass && handles.mOldImage.mIndex == handles.mReplacementImage.mIndex && + handles.mOldImage.mGeneration != std::numeric_limits::max() && + handles.mReplacementImage.mGeneration == handles.mOldImage.mGeneration + 1 && + handles.mOutput.mIndex != handles.mOldImage.mIndex; +} + +bool canonicalRequest(const StreamingUploadInputs& inputs) +{ + return inputs.mFrame != 0 && validHandles(inputs.mHandles) && inputs.mRevision == TEXTURE_UPLOAD_REVISION && + inputs.mSubresource == ImageSubresource{} && sameOffset(inputs.mOffset, {}) && sameExtent(inputs.mExtent, RESIDENT_EXTENT) && + sameExtent(inputs.mLogicalExtent, LOGICAL_EXTENT) && inputs.mResidentDiscard == TEXTURE_UPLOAD_RESIDENT_DISCARD && + inputs.mSourceFormat == PixelFormat::RGBA8Unorm && inputs.mRowPitch == TEXTURE_UPLOAD_ROW_PITCH && + inputs.mRowOrigin == RowOrigin::TopLeft && inputs.mMipGeneration == MipGeneration::GenerateRemaining && + inputs.mPixels.size() == TEXTURE_UPLOAD_SOURCE_BYTE_COUNT && inputs.mBefore == ImageState::Undefined && + inputs.mDuring == ImageState::TransferDestination && inputs.mAfter == ImageState::ShaderRead; +} + +ImageResource image(ImageHandle handle, Extent2D extent, std::uint32_t mip_levels, ResourceLifetime lifetime) +{ + ImageResource result; + result.mHandle = handle; + result.mExtent = extent; + result.mMipLevels = mip_levels; + result.mFormat = PixelFormat::RGBA8Unorm; + result.mLifetime = lifetime; + return result; +} + +bool canonicalImage(const ImageResource& resource, ImageHandle handle, Extent2D extent, std::uint32_t mip_levels, + ResourceLifetime lifetime) +{ + return resource.mHandle == handle && sameExtent(resource.mExtent, extent) && resource.mMipLevels == mip_levels && + resource.mArrayLayers == 1 && resource.mSamples == 1 && resource.mFormat == PixelFormat::RGBA8Unorm && + resource.mLifetime == lifetime; +} + +bool canonicalSampledAccess(const ImageAccess& access, ImageHandle handle) +{ + return access.mImage == handle && sameRange(access.mRange, REPLACEMENT_RANGE) && + access.mKind == ImageAccessKind::SampledRead && access.mBefore == ImageState::ShaderRead && + access.mDuring == ImageState::ShaderRead && access.mAfter == ImageState::ShaderRead; +} + +bool canonicalOutputAccess(const ImageAccess& access, ImageHandle handle) +{ + return access.mImage == handle && sameRange(access.mRange, {}) && access.mKind == ImageAccessKind::ColorAttachmentWrite && + access.mBefore == ImageState::Undefined && access.mDuring == ImageState::ColorAttachment && + access.mAfter == ImageState::ShaderRead; +} + +ByteRange ownedPixels(const std::vector& pixels) +{ + auto storage = std::make_shared>(pixels); + return { std::move(storage), 0, pixels.size() }; +} + +} // namespace + +std::optional buildStreamingUploadFrame(const StreamingUploadInputs& inputs) +{ + if (!canonicalRequest(inputs)) + { + return std::nullopt; + } + + const StreamingUploadHandles& handles = inputs.mHandles; + FrameSnapshot frame; + frame.mFrame = inputs.mFrame; + frame.mBuffers = { { handles.mScreenTriangle, 48, ResourceLifetime::Persistent } }; + frame.mImages = { image(handles.mOldImage, RESIDENT_EXTENT, TEXTURE_UPLOAD_MIP_LEVELS, ResourceLifetime::Persistent), + image(handles.mReplacementImage, RESIDENT_EXTENT, TEXTURE_UPLOAD_MIP_LEVELS, + ResourceLifetime::Persistent), + image(handles.mOutput, OUTPUT_EXTENT, 1, ResourceLifetime::External) }; + + SamplerResource sampler; + sampler.mHandle = handles.mSampler; + sampler.mMinFilter = Filter::Linear; + sampler.mMagFilter = Filter::Linear; + sampler.mMipFilter = MipFilter::Linear; + sampler.mAddressU = AddressMode::Clamp; + sampler.mAddressV = AddressMode::Clamp; + frame.mSamplers.push_back(sampler); + + PipelineResource pipeline; + pipeline.mHandle = handles.mPipeline; + pipeline.mProgram = { TEXTURE_UPLOAD_PROGRAM, 0 }; + pipeline.mCullMode = CullMode::Disabled; + pipeline.mDepthCompare = CompareOp::LessOrEqual; + pipeline.mColorTargets = { { PixelFormat::RGBA8Unorm, false, 0xf } }; + pipeline.mVertexBindings.push_back({ 0, 16 }); + pipeline.mVertexAttributes.push_back({ VertexSemantic::Position, VertexFormat::Float3, 0, 0 }); + pipeline.mSampledImageBindings = { 0 }; + frame.mPipelines.push_back(std::move(pipeline)); + + TextureUpload upload; + upload.mDestination = handles.mReplacementImage; + upload.mRevision = inputs.mRevision; + upload.mSubresource = inputs.mSubresource; + upload.mOffset = inputs.mOffset; + upload.mExtent = inputs.mExtent; + upload.mLogicalExtent = inputs.mLogicalExtent; + upload.mResidentDiscard = inputs.mResidentDiscard; + upload.mSourceFormat = inputs.mSourceFormat; + upload.mRowPitch = inputs.mRowPitch; + upload.mRowOrigin = inputs.mRowOrigin; + upload.mMipGeneration = inputs.mMipGeneration; + upload.mPixels = ownedPixels(inputs.mPixels); + upload.mBefore = inputs.mBefore; + upload.mDuring = inputs.mDuring; + upload.mAfter = inputs.mAfter; + frame.mUploads.push_back(std::move(upload)); + + RenderPass pass; + pass.mId = handles.mPass; + pass.mLabel = TEXTURE_UPLOAD_PASS; + pass.mExtent = OUTPUT_EXTENT; + pass.mViewport = { 0.f, 0.f, static_cast(TEXTURE_UPLOAD_OUTPUT_WIDTH), + static_cast(TEXTURE_UPLOAD_OUTPUT_HEIGHT), 0.f, 1.f }; + pass.mScissor = { 0, 0, TEXTURE_UPLOAD_OUTPUT_WIDTH, TEXTURE_UPLOAD_OUTPUT_HEIGHT }; + pass.mBufferAccesses.push_back({ handles.mScreenTriangle, BufferAccessKind::VertexRead }); + pass.mImageAccesses = { + { handles.mReplacementImage, REPLACEMENT_RANGE, ImageAccessKind::SampledRead, ImageState::ShaderRead, + ImageState::ShaderRead, ImageState::ShaderRead }, + { handles.mOutput, {}, ImageAccessKind::ColorAttachmentWrite, ImageState::Undefined, ImageState::ColorAttachment, + ImageState::ShaderRead } + }; + pass.mColorAttachments.push_back({ handles.mOutput, {}, LoadOp::DontCare, StoreOp::Store, {} }); + + Draw draw; + draw.mResources.mPipeline = handles.mPipeline; + draw.mResources.mVertexBuffers.push_back({ 0, handles.mScreenTriangle, 0 }); + draw.mResources.mSampledImages.push_back({ 0, handles.mReplacementImage, REPLACEMENT_RANGE, handles.mSampler }); + draw.mVertexCount = 3; + pass.mDraws.emplace_back(std::move(draw)); + frame.mPasses.push_back(std::move(pass)); + frame.mReleases.push_back({ ResourceHandle{ handles.mOldImage }, inputs.mFrame }); + + if (!static_cast(validate(frame))) + { + return std::nullopt; + } + return frame; +} + +std::optional decodeStreamingUploadFrame(const FrameSnapshot& frame) +{ + if (!static_cast(validate(frame)) || frame.mBuffers.size() != 1 || frame.mImages.size() != 3 || + frame.mSamplers.size() != 1 || frame.mPipelines.size() != 1 || frame.mUploads.size() != 1 || + frame.mPasses.size() != 1 || frame.mReleases.size() != 1) + { + return std::nullopt; + } + + const BufferResource& screen = frame.mBuffers[0]; + const ImageResource& old_image = frame.mImages[0]; + const ImageResource& replacement = frame.mImages[1]; + const ImageResource& output = frame.mImages[2]; + const SamplerResource& sampler = frame.mSamplers[0]; + const PipelineResource& pipeline = frame.mPipelines[0]; + const TextureUpload& upload = frame.mUploads[0]; + const RenderPass& pass = frame.mPasses[0]; + const ReleaseAfterFrame& release = frame.mReleases[0]; + + StreamingUploadInputs result; + result.mFrame = frame.mFrame; + result.mHandles = { screen.mHandle, old_image.mHandle, replacement.mHandle, output.mHandle, sampler.mHandle, + pipeline.mHandle, pass.mId }; + result.mRevision = upload.mRevision; + result.mSubresource = upload.mSubresource; + result.mOffset = upload.mOffset; + result.mExtent = upload.mExtent; + result.mLogicalExtent = upload.mLogicalExtent; + result.mResidentDiscard = upload.mResidentDiscard; + result.mSourceFormat = upload.mSourceFormat; + result.mRowPitch = upload.mRowPitch; + result.mRowOrigin = upload.mRowOrigin; + result.mMipGeneration = upload.mMipGeneration; + result.mBefore = upload.mBefore; + result.mDuring = upload.mDuring; + result.mAfter = upload.mAfter; + + if (!validHandles(result.mHandles) || screen.mSize != 48 || screen.mLifetime != ResourceLifetime::Persistent || + !canonicalImage(old_image, result.mHandles.mOldImage, RESIDENT_EXTENT, TEXTURE_UPLOAD_MIP_LEVELS, + ResourceLifetime::Persistent) || + !canonicalImage(replacement, result.mHandles.mReplacementImage, RESIDENT_EXTENT, TEXTURE_UPLOAD_MIP_LEVELS, + ResourceLifetime::Persistent) || + !canonicalImage(output, result.mHandles.mOutput, OUTPUT_EXTENT, 1, ResourceLifetime::External)) + { + return std::nullopt; + } + + if (sampler.mHandle != result.mHandles.mSampler || sampler.mMinFilter != Filter::Linear || + sampler.mMagFilter != Filter::Linear || sampler.mMipFilter != MipFilter::Linear || + sampler.mAddressU != AddressMode::Clamp || sampler.mAddressV != AddressMode::Clamp || sampler.mMaxAnisotropy != 1.f || + sampler.mLifetime != ResourceLifetime::Persistent) + { + return std::nullopt; + } + + if (pipeline.mProgram.mName != TEXTURE_UPLOAD_PROGRAM || pipeline.mProgram.mVariant != 0 || + pipeline.mTopology != PrimitiveTopology::TriangleList || pipeline.mCullMode != CullMode::Disabled || + pipeline.mFrontFace != FrontFace::CounterClockwise || pipeline.mDepthTestEnabled || pipeline.mDepthWriteEnabled || + pipeline.mDepthCompare != CompareOp::LessOrEqual || pipeline.mSamples != 1 || pipeline.mDepthFormat || + pipeline.mLifetime != ResourceLifetime::Persistent || pipeline.mColorTargets.size() != 1 || + pipeline.mColorTargets[0].mFormat != PixelFormat::RGBA8Unorm || pipeline.mColorTargets[0].mBlendEnabled || + pipeline.mColorTargets[0].mWriteMask != 0xf || pipeline.mVertexBindings.size() != 1 || + pipeline.mVertexBindings[0].mBinding != 0 || pipeline.mVertexBindings[0].mStride != 16 || + pipeline.mVertexAttributes.size() != 1 || pipeline.mVertexAttributes[0].mSemantic != VertexSemantic::Position || + pipeline.mVertexAttributes[0].mFormat != VertexFormat::Float3 || pipeline.mVertexAttributes[0].mBinding != 0 || + pipeline.mVertexAttributes[0].mOffset != 0 || pipeline.mSampledImageBindings != std::vector{ 0 } || + !pipeline.mParameterBindings.empty()) + { + return std::nullopt; + } + + if (upload.mDestination != result.mHandles.mReplacementImage || !upload.mPixels.mStorage || upload.mPixels.mOffset != 0 || + upload.mPixels.mSize != TEXTURE_UPLOAD_SOURCE_BYTE_COUNT || + upload.mPixels.mStorage->size() != TEXTURE_UPLOAD_SOURCE_BYTE_COUNT) + { + return std::nullopt; + } + result.mPixels.assign(upload.mPixels.mStorage->begin(), upload.mPixels.mStorage->end()); + if (!canonicalRequest(result)) + { + return std::nullopt; + } + + if (pass.mId != result.mHandles.mPass || pass.mLabel != TEXTURE_UPLOAD_PASS || !sameExtent(pass.mExtent, OUTPUT_EXTENT) || + pass.mViewport.mX != 0.f || pass.mViewport.mY != 0.f || pass.mViewport.mWidth != TEXTURE_UPLOAD_OUTPUT_WIDTH || + pass.mViewport.mHeight != TEXTURE_UPLOAD_OUTPUT_HEIGHT || pass.mViewport.mMinDepth != 0.f || pass.mViewport.mMaxDepth != 1.f || + pass.mScissor.mX != 0 || pass.mScissor.mY != 0 || pass.mScissor.mWidth != TEXTURE_UPLOAD_OUTPUT_WIDTH || + pass.mScissor.mHeight != TEXTURE_UPLOAD_OUTPUT_HEIGHT || !pass.mDependencies.empty() || pass.mBufferAccesses.size() != 1 || + pass.mBufferAccesses[0].mBuffer != result.mHandles.mScreenTriangle || + pass.mBufferAccesses[0].mKind != BufferAccessKind::VertexRead || pass.mImageAccesses.size() != 2 || + !canonicalSampledAccess(pass.mImageAccesses[0], result.mHandles.mReplacementImage) || + !canonicalOutputAccess(pass.mImageAccesses[1], result.mHandles.mOutput) || pass.mColorAttachments.size() != 1 || + pass.mColorAttachments[0].mImage != result.mHandles.mOutput || pass.mColorAttachments[0].mSubresource != ImageSubresource{} || + pass.mColorAttachments[0].mLoad != LoadOp::DontCare || pass.mColorAttachments[0].mStore != StoreOp::Store || + !zeroClear(pass.mColorAttachments[0].mClear) || pass.mDepthAttachment || pass.mDraws.size() != 1 || + !std::holds_alternative(pass.mDraws[0])) + { + return std::nullopt; + } + + const Draw& draw = std::get(pass.mDraws[0]); + if (draw.mResources.mPipeline != result.mHandles.mPipeline || draw.mResources.mVertexBuffers.size() != 1 || + draw.mResources.mVertexBuffers[0].mBinding != 0 || + draw.mResources.mVertexBuffers[0].mBuffer != result.mHandles.mScreenTriangle || + draw.mResources.mVertexBuffers[0].mOffset != 0 || draw.mResources.mSampledImages.size() != 1 || + draw.mResources.mSampledImages[0].mBinding != 0 || + draw.mResources.mSampledImages[0].mImage != result.mHandles.mReplacementImage || + !sameRange(draw.mResources.mSampledImages[0].mRange, REPLACEMENT_RANGE) || + draw.mResources.mSampledImages[0].mSampler != result.mHandles.mSampler || !draw.mResources.mParameters.empty() || + draw.mFirstVertex != 0 || draw.mVertexCount != 3 || draw.mFirstInstance != 0 || draw.mInstanceCount != 1) + { + return std::nullopt; + } + + if (!std::holds_alternative(release.mResource) || + std::get(release.mResource) != result.mHandles.mOldImage || release.mFrame != result.mFrame) + { + return std::nullopt; + } + + return result; +} + +} // namespace LLRenderContract diff --git a/indra/llrender/lltextureuploadcontract.h b/indra/llrender/lltextureuploadcontract.h new file mode 100644 index 00000000000..296f55da4a9 --- /dev/null +++ b/indra/llrender/lltextureuploadcontract.h @@ -0,0 +1,113 @@ +/** + * @file lltextureuploadcontract.h + * @brief Backend-neutral description of one streamed texture replacement. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLTEXTUREUPLOADCONTRACT_H +#define LL_LLTEXTUREUPLOADCONTRACT_H + +#include "llrendercontract.h" + +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ + +inline constexpr std::uint32_t TEXTURE_UPLOAD_RESIDENT_WIDTH = 8; +inline constexpr std::uint32_t TEXTURE_UPLOAD_RESIDENT_HEIGHT = 4; +inline constexpr std::uint32_t TEXTURE_UPLOAD_LOGICAL_WIDTH = 32; +inline constexpr std::uint32_t TEXTURE_UPLOAD_LOGICAL_HEIGHT = 16; +inline constexpr std::uint32_t TEXTURE_UPLOAD_OUTPUT_WIDTH = 4; +inline constexpr std::uint32_t TEXTURE_UPLOAD_OUTPUT_HEIGHT = 2; +inline constexpr std::uint32_t TEXTURE_UPLOAD_MIP_LEVELS = 3; +inline constexpr std::uint32_t TEXTURE_UPLOAD_CHANNELS = 4; +inline constexpr std::uint32_t TEXTURE_UPLOAD_RESIDENT_DISCARD = 2; +inline constexpr std::uint32_t TEXTURE_UPLOAD_ROW_PITCH = 36; + +inline constexpr std::size_t TEXTURE_UPLOAD_SOURCE_BYTE_COUNT = + static_cast(TEXTURE_UPLOAD_ROW_PITCH) * TEXTURE_UPLOAD_RESIDENT_HEIGHT; +inline constexpr std::array TEXTURE_UPLOAD_MIP_BYTE_OFFSETS{ 0, 128, 160 }; +inline constexpr std::array TEXTURE_UPLOAD_MIP_BYTE_SIZES{ 128, 32, 8 }; +inline constexpr std::size_t TEXTURE_UPLOAD_MIP_BYTE_COUNT = 168; +inline constexpr std::size_t TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT = + static_cast(TEXTURE_UPLOAD_OUTPUT_WIDTH) * TEXTURE_UPLOAD_OUTPUT_HEIGHT * TEXTURE_UPLOAD_CHANNELS; + +inline constexpr std::uint64_t TEXTURE_UPLOAD_PRIOR_REVISION = 22; +inline constexpr std::uint64_t TEXTURE_UPLOAD_REVISION = 23; + +struct StreamingUploadHandles +{ + BufferHandle mScreenTriangle{ 1, 1 }; + ImageHandle mOldImage{ 11, 1 }; + ImageHandle mReplacementImage{ 11, 2 }; + ImageHandle mOutput{ 12, 1 }; + SamplerHandle mSampler{ 1, 1 }; + PipelineHandle mPipeline{ 1, 1 }; + PassId mPass{ 1 }; + + friend constexpr bool operator==(const StreamingUploadHandles&, const StreamingUploadHandles&) = default; +}; + +struct StreamingUploadInputs +{ + std::uint64_t mFrame = 0; + StreamingUploadHandles mHandles; + std::uint64_t mRevision = TEXTURE_UPLOAD_REVISION; + ImageSubresource mSubresource; + Offset2D mOffset; + Extent2D mExtent{ TEXTURE_UPLOAD_RESIDENT_WIDTH, TEXTURE_UPLOAD_RESIDENT_HEIGHT }; + Extent2D mLogicalExtent{ TEXTURE_UPLOAD_LOGICAL_WIDTH, TEXTURE_UPLOAD_LOGICAL_HEIGHT }; + std::uint32_t mResidentDiscard = TEXTURE_UPLOAD_RESIDENT_DISCARD; + PixelFormat mSourceFormat = PixelFormat::RGBA8Unorm; + std::uint32_t mRowPitch = TEXTURE_UPLOAD_ROW_PITCH; + RowOrigin mRowOrigin = RowOrigin::TopLeft; + MipGeneration mMipGeneration = MipGeneration::GenerateRemaining; + std::vector mPixels; + ImageState mBefore = ImageState::Undefined; + ImageState mDuring = ImageState::TransferDestination; + ImageState mAfter = ImageState::ShaderRead; +}; + +// Backend-neutral publication state for one streamed image generation. A +// backend may keep the retired physical object alive after this ledger changes. +struct StreamingUploadLifecycle +{ + ImageHandle mCurrentImage; + std::uint64_t mLastRevision = 0; + bool mCompletionPending = false; + + std::uint32_t mCompletionCount = 0; + ImageHandle mCompletedDestination; + std::uint64_t mCompletedRevision = 0; + std::uint64_t mCompletedFrame = 0; + + std::uint32_t mRetirementCount = 0; + ImageHandle mRetiredResource; + std::uint64_t mRetirementFrame = 0; + + friend constexpr bool operator==(const StreamingUploadLifecycle&, const StreamingUploadLifecycle&) = default; +}; + +// Copies the caller's pixels into storage owned by the returned frame. +std::optional buildStreamingUploadFrame(const StreamingUploadInputs& inputs); + +// Accepts only the fixed streaming-upload packet shape and returns owned pixels. +std::optional decodeStreamingUploadFrame(const FrameSnapshot& frame); + +} // namespace LLRenderContract + +#endif // LL_LLTEXTUREUPLOADCONTRACT_H diff --git a/indra/llrender/lltextureuploaddiagnostic.cpp b/indra/llrender/lltextureuploaddiagnostic.cpp new file mode 100644 index 00000000000..c070d40faca --- /dev/null +++ b/indra/llrender/lltextureuploaddiagnostic.cpp @@ -0,0 +1,705 @@ +/** + * @file lltextureuploaddiagnostic.cpp + * @brief Deterministic fixture and artifact for a streamed texture upload. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "lltextureuploaddiagnostic.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + +constexpr std::array ARTIFACT_MAGIC{ 'L', 'L', 'U', 'P', 'L', 'O', 'A', 'D' }; +constexpr std::uint32_t RGBA8_WIRE_VALUE = 1; +constexpr std::uint32_t TOP_LEFT_WIRE_VALUE = 1; +constexpr std::uint32_t BOTTOM_LEFT_WIRE_VALUE = 2; +constexpr std::uint32_t GENERATE_MIPS_WIRE_VALUE = 1; +constexpr std::uint32_t UNDEFINED_WIRE_VALUE = 1; +constexpr std::uint32_t TRANSFER_DEST_WIRE_VALUE = 2; +constexpr std::uint32_t SHADER_READ_WIRE_VALUE = 3; +constexpr std::array MIP_WIDTHS{ 8, 4, 2 }; +constexpr std::array MIP_HEIGHTS{ 4, 2, 1 }; +constexpr std::size_t ARTIFACT_CONTENT_SIZE = 472; +constexpr std::size_t ARTIFACT_SIZE = ARTIFACT_CONTENT_SIZE + sizeof(std::uint64_t); + +static_assert(ARTIFACT_SIZE == TEXTURE_UPLOAD_ARTIFACT_BYTE_SIZE); +static_assert(TEXTURE_UPLOAD_MIP_BYTE_OFFSETS[2] + TEXTURE_UPLOAD_MIP_BYTE_SIZES[2] == TEXTURE_UPLOAD_MIP_BYTE_COUNT); + +void clearError(std::string* error) +{ + if (error) + { + error->clear(); + } +} + +bool fail(std::string* error, std::string message) +{ + if (error) + { + *error = std::move(message); + } + return false; +} + +void appendU32(std::vector& bytes, std::uint32_t value) +{ + bytes.push_back(static_cast(value >> 24)); + bytes.push_back(static_cast(value >> 16)); + bytes.push_back(static_cast(value >> 8)); + bytes.push_back(static_cast(value)); +} + +void appendU64(std::vector& bytes, std::uint64_t value) +{ + appendU32(bytes, static_cast(value >> 32)); + appendU32(bytes, static_cast(value)); +} + +class ArtifactReader +{ +public: + explicit ArtifactReader(const std::vector& bytes) : mBytes(bytes) {} + + bool readU32(std::uint32_t& value) + { + if (mOffset > mBytes.size() || mBytes.size() - mOffset < sizeof(value)) + { + return false; + } + value = (static_cast(mBytes[mOffset]) << 24) | + (static_cast(mBytes[mOffset + 1]) << 16) | + (static_cast(mBytes[mOffset + 2]) << 8) | + static_cast(mBytes[mOffset + 3]); + mOffset += sizeof(value); + return true; + } + + bool readU64(std::uint64_t& value) + { + std::uint32_t high = 0; + std::uint32_t low = 0; + if (!readU32(high) || !readU32(low)) + { + return false; + } + value = (static_cast(high) << 32) | low; + return true; + } + + bool readBytes(std::uint8_t* destination, std::size_t size) + { + if (mOffset > mBytes.size() || mBytes.size() - mOffset < size) + { + return false; + } + std::copy_n(mBytes.data() + mOffset, size, destination); + mOffset += size; + return true; + } + + std::size_t offset() const noexcept { return mOffset; } + +private: + const std::vector& mBytes; + std::size_t mOffset = 0; +}; + +bool expectedU32(ArtifactReader& reader, std::uint32_t expected, const std::string& field, std::string* error) +{ + std::uint32_t actual = 0; + if (!reader.readU32(actual)) + { + return fail(error, "artifact ends before " + field); + } + if (actual != expected) + { + std::ostringstream message; + message << field << " is " << actual << ", expected " << expected; + return fail(error, message.str()); + } + return true; +} + +bool expectedU64(ArtifactReader& reader, std::uint64_t expected, const std::string& field, std::string* error) +{ + std::uint64_t actual = 0; + if (!reader.readU64(actual)) + { + return fail(error, "artifact ends before " + field); + } + if (actual != expected) + { + std::ostringstream message; + message << field << " is " << actual << ", expected " << expected; + return fail(error, message.str()); + } + return true; +} + +void hashByte(std::uint64_t& hash, std::uint8_t value) +{ + constexpr std::uint64_t FNV_PRIME = 1099511628211ULL; + hash ^= value; + hash *= FNV_PRIME; +} + +void hashU32(std::uint64_t& hash, std::uint32_t value) +{ + hashByte(hash, static_cast(value >> 24)); + hashByte(hash, static_cast(value >> 16)); + hashByte(hash, static_cast(value >> 8)); + hashByte(hash, static_cast(value)); +} + +void hashU64(std::uint64_t& hash, std::uint64_t value) +{ + hashU32(hash, static_cast(value >> 32)); + hashU32(hash, static_cast(value)); +} + +std::uint64_t artifactChecksum(const std::uint8_t* bytes, std::size_t size) +{ + constexpr std::uint64_t FNV_OFFSET_BASIS = 14695981039346656037ULL; + std::uint64_t hash = FNV_OFFSET_BASIS; + for (std::size_t index = 0; index < size; ++index) + { + hashByte(hash, bytes[index]); + } + return hash; +} + +std::uint32_t floatBits(float value) +{ + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; +} + +std::filesystem::path temporaryArtifactPath(const std::filesystem::path& destination) +{ + static std::atomic serial{ 0 }; + const auto now = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::uint64_t sequence = serial.fetch_add(1, std::memory_order_relaxed); + const std::uint64_t token = + static_cast(now) ^ (sequence + 0x9e3779b97f4a7c15ULL + (sequence << 6) + (sequence >> 2)); + std::ostringstream suffix; + suffix << ".tmp." << std::hex << token; + std::filesystem::path temporary = destination; + temporary += suffix.str(); + return temporary; +} + +std::FILE* openExclusive(const std::filesystem::path& path, int& open_error) +{ +#if defined(_WIN32) + std::FILE* file = nullptr; + open_error = _wfopen_s(&file, path.c_str(), L"wbx"); + return file; +#else + errno = 0; + std::FILE* file = std::fopen(path.c_str(), "wbx"); + open_error = errno; + return file; +#endif +} + +} // namespace + +TextureUploadFixture makeTextureUploadFixture() +{ + TextureUploadFixture fixture; + fixture.mScreenTriangle = { -1.f, 1.f, 0.f, 0.f, -1.f, -3.f, 0.f, 0.f, 3.f, 1.f, 0.f, 0.f }; + + for (std::size_t top_row = 0; top_row < TEXTURE_UPLOAD_RESIDENT_HEIGHT; ++top_row) + { + const std::size_t row_start = top_row * TEXTURE_UPLOAD_ROW_PITCH; + for (std::size_t x = 0; x < TEXTURE_UPLOAD_RESIDENT_WIDTH; ++x) + { + for (std::size_t channel = 0; channel < TEXTURE_UPLOAD_CHANNELS; ++channel) + { + fixture.mSourceRGBA8[row_start + x * TEXTURE_UPLOAD_CHANNELS + channel] = + static_cast(5 + channel * 64 + top_row * 8 + x * 2); + } + } + for (std::size_t padding = TEXTURE_UPLOAD_RESIDENT_WIDTH * TEXTURE_UPLOAD_CHANNELS; + padding < TEXTURE_UPLOAD_ROW_PITCH; ++padding) + { + fixture.mSourceRGBA8[row_start + padding] = + static_cast(0xf0U + top_row * 4 + padding - + TEXTURE_UPLOAD_RESIDENT_WIDTH * TEXTURE_UPLOAD_CHANNELS); + } + } + + for (std::size_t mip = 0; mip < TEXTURE_UPLOAD_MIP_LEVELS; ++mip) + { + const std::size_t begin = TEXTURE_UPLOAD_MIP_BYTE_OFFSETS[mip]; + const std::size_t size = TEXTURE_UPLOAD_MIP_BYTE_SIZES[mip]; + for (std::size_t component = 0; component < size; ++component) + { + fixture.mOldMipRGBA8[begin + component] = + static_cast(1 + (mip * 73 + component * 31 + 17) % 251); + fixture.mReplacementSentinelMipRGBA8[begin + component] = + static_cast(1 + (mip * 41 + component * 19 + 137) % 251); + } + } + for (std::size_t component = 0; component < fixture.mOutputSentinelRGBA8.size(); ++component) + { + fixture.mOutputSentinelRGBA8[component] = static_cast(1 + (component * 43 + 89) % 251); + } + return fixture; +} + +std::uint64_t textureUploadFixtureFingerprint() +{ + constexpr std::uint64_t FNV_OFFSET_BASIS = 14695981039346656037ULL; + const TextureUploadFixture fixture = makeTextureUploadFixture(); + std::uint64_t hash = FNV_OFFSET_BASIS; + hashU32(hash, TEXTURE_UPLOAD_DIAGNOSTIC_FIXTURE_VERSION); + hashU32(hash, fixture.mResidentExtent.mWidth); + hashU32(hash, fixture.mResidentExtent.mHeight); + hashU32(hash, fixture.mLogicalExtent.mWidth); + hashU32(hash, fixture.mLogicalExtent.mHeight); + hashU32(hash, fixture.mOutputExtent.mWidth); + hashU32(hash, fixture.mOutputExtent.mHeight); + hashU32(hash, TOP_LEFT_WIRE_VALUE); + hashU32(hash, fixture.mSourceRowPitch); + hashU64(hash, fixture.mPriorRevision); + for (std::uint8_t value : fixture.mSourceRGBA8) + hashByte(hash, value); + for (float value : fixture.mScreenTriangle) + hashU32(hash, floatBits(value)); + for (std::uint8_t value : fixture.mOldMipRGBA8) + hashByte(hash, value); + for (std::uint8_t value : fixture.mReplacementSentinelMipRGBA8) + hashByte(hash, value); + for (std::uint8_t value : fixture.mOutputSentinelRGBA8) + hashByte(hash, value); + return hash; +} + +TextureUploadCase makeTextureUploadCase() +{ + TextureUploadCase result; + const TextureUploadFixture fixture = makeTextureUploadFixture(); + result.mPriorRevision = fixture.mPriorRevision; + result.mInputs.mFrame = TEXTURE_UPLOAD_DIAGNOSTIC_FRAME; + result.mInputs.mPixels.assign(fixture.mSourceRGBA8.begin(), fixture.mSourceRGBA8.end()); + auto frame = buildStreamingUploadFrame(result.mInputs); + if (!frame) + { + std::terminate(); + } + result.mFrame = std::move(*frame); + return result; +} + +TextureUploadArtifact makeTextureUploadArtifact() +{ + return {}; +} + +bool validateTextureUploadArtifact(const TextureUploadArtifact& artifact, std::string* error) +{ + clearError(error); + if (artifact.mPriorRevision != TEXTURE_UPLOAD_PRIOR_REVISION || artifact.mRevision != TEXTURE_UPLOAD_REVISION || + artifact.mCompletionCount != 1 || artifact.mCompletedDestination != ImageHandle{ 11, 2 } || + artifact.mCompletedRevision != TEXTURE_UPLOAD_REVISION || artifact.mCompletedFrame != TEXTURE_UPLOAD_DIAGNOSTIC_FRAME || + artifact.mRetirementCount != 1 || artifact.mRetiredResource != ImageHandle{ 11, 1 } || + artifact.mRetirementFrame != artifact.mCompletedFrame || !artifact.mOldResolvableBefore || artifact.mOldResolvableAfter || + !artifact.mReplacementResolvableAfter) + { + return fail(error, "artifact revision, completion, retirement, or post-state metadata is not canonical"); + } + for (std::size_t mip = 0; mip < artifact.mMipRGBA8.size(); ++mip) + { + if (artifact.mMipRGBA8[mip].size() != TEXTURE_UPLOAD_MIP_BYTE_SIZES[mip]) + { + std::ostringstream message; + message << "mip " << mip << " has " << artifact.mMipRGBA8[mip].size() << " bytes, expected " + << TEXTURE_UPLOAD_MIP_BYTE_SIZES[mip]; + return fail(error, message.str()); + } + } + if (artifact.mSampledRGBA8.size() != TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT) + { + std::ostringstream message; + message << "sampled output has " << artifact.mSampledRGBA8.size() << " bytes, expected " + << TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT; + return fail(error, message.str()); + } + return true; +} + +bool encodeTextureUploadArtifact(const TextureUploadArtifact& artifact, std::vector& encoded, std::string* error) +{ + clearError(error); + if (!validateTextureUploadArtifact(artifact, error)) + { + return false; + } + + std::vector result; + result.reserve(ARTIFACT_SIZE); + result.insert(result.end(), ARTIFACT_MAGIC.begin(), ARTIFACT_MAGIC.end()); + appendU32(result, TEXTURE_UPLOAD_ARTIFACT_SCHEMA_VERSION); + appendU32(result, TEXTURE_UPLOAD_DIAGNOSTIC_FIXTURE_VERSION); + appendU64(result, textureUploadFixtureFingerprint()); + appendU64(result, artifact.mCompletedFrame); + appendU64(result, artifact.mPriorRevision); + appendU64(result, artifact.mRevision); + appendU32(result, 11); + appendU32(result, 1); + appendU32(result, 11); + appendU32(result, 2); + appendU32(result, 0); + appendU32(result, 0); + appendU32(result, 0); + appendU32(result, 0); + appendU32(result, TEXTURE_UPLOAD_RESIDENT_WIDTH); + appendU32(result, TEXTURE_UPLOAD_RESIDENT_HEIGHT); + appendU32(result, TEXTURE_UPLOAD_RESIDENT_WIDTH); + appendU32(result, TEXTURE_UPLOAD_RESIDENT_HEIGHT); + appendU32(result, TEXTURE_UPLOAD_LOGICAL_WIDTH); + appendU32(result, TEXTURE_UPLOAD_LOGICAL_HEIGHT); + appendU32(result, TEXTURE_UPLOAD_RESIDENT_DISCARD); + appendU32(result, RGBA8_WIRE_VALUE); + appendU32(result, RGBA8_WIRE_VALUE); + appendU32(result, TOP_LEFT_WIRE_VALUE); + appendU32(result, BOTTOM_LEFT_WIRE_VALUE); + appendU32(result, TEXTURE_UPLOAD_ROW_PITCH); + appendU32(result, GENERATE_MIPS_WIRE_VALUE); + appendU32(result, UNDEFINED_WIRE_VALUE); + appendU32(result, TRANSFER_DEST_WIRE_VALUE); + appendU32(result, SHADER_READ_WIRE_VALUE); + appendU32(result, TEXTURE_UPLOAD_MIP_LEVELS); + appendU32(result, TEXTURE_UPLOAD_OUTPUT_WIDTH); + appendU32(result, TEXTURE_UPLOAD_OUTPUT_HEIGHT); + appendU32(result, RGBA8_WIRE_VALUE); + appendU32(result, TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT); + appendU32(result, artifact.mCompletionCount); + appendU32(result, artifact.mCompletedDestination.mIndex); + appendU32(result, artifact.mCompletedDestination.mGeneration); + appendU64(result, artifact.mCompletedRevision); + appendU64(result, artifact.mCompletedFrame); + appendU32(result, artifact.mRetirementCount); + appendU32(result, artifact.mRetiredResource.mIndex); + appendU32(result, artifact.mRetiredResource.mGeneration); + appendU64(result, artifact.mRetirementFrame); + appendU32(result, artifact.mOldResolvableBefore ? 1 : 0); + appendU32(result, artifact.mOldResolvableAfter ? 1 : 0); + appendU32(result, artifact.mReplacementResolvableAfter ? 1 : 0); + + for (std::size_t mip = 0; mip < TEXTURE_UPLOAD_MIP_LEVELS; ++mip) + { + appendU32(result, static_cast(mip)); + appendU32(result, MIP_WIDTHS[mip]); + appendU32(result, MIP_HEIGHTS[mip]); + appendU32(result, static_cast(artifact.mMipRGBA8[mip].size())); + result.insert(result.end(), artifact.mMipRGBA8[mip].begin(), artifact.mMipRGBA8[mip].end()); + } + result.insert(result.end(), artifact.mSampledRGBA8.begin(), artifact.mSampledRGBA8.end()); + + if (result.size() != ARTIFACT_CONTENT_SIZE) + { + return fail(error, "artifact encoder produced an unexpected byte count"); + } + appendU64(result, artifactChecksum(result.data(), result.size())); + if (result.size() != ARTIFACT_SIZE) + { + return fail(error, "artifact encoder produced an unexpected checksum byte count"); + } + encoded = std::move(result); + return true; +} + +bool decodeTextureUploadArtifact(const std::vector& encoded, TextureUploadArtifact& artifact, std::string* error) +{ + clearError(error); + if (encoded.size() != ARTIFACT_SIZE) + { + std::ostringstream message; + message << "artifact has " << encoded.size() << " bytes, expected " << ARTIFACT_SIZE; + return fail(error, message.str()); + } + + ArtifactReader checksum_reader(encoded); + std::array content{}; + std::uint64_t stored_checksum = 0; + if (!checksum_reader.readBytes(content.data(), content.size()) || !checksum_reader.readU64(stored_checksum)) + { + return fail(error, "artifact checksum is truncated"); + } + const std::uint64_t computed_checksum = artifactChecksum(content.data(), content.size()); + if (stored_checksum != computed_checksum) + { + return fail(error, "artifact checksum is invalid"); + } + + ArtifactReader reader(encoded); + std::array magic{}; + if (!reader.readBytes(magic.data(), magic.size()) || magic != ARTIFACT_MAGIC) + { + return fail(error, "artifact magic is invalid"); + } + + if (!expectedU32(reader, TEXTURE_UPLOAD_ARTIFACT_SCHEMA_VERSION, "schema version", error) || + !expectedU32(reader, TEXTURE_UPLOAD_DIAGNOSTIC_FIXTURE_VERSION, "fixture version", error) || + !expectedU64(reader, textureUploadFixtureFingerprint(), "fixture fingerprint", error) || + !expectedU64(reader, TEXTURE_UPLOAD_DIAGNOSTIC_FRAME, "frame", error) || + !expectedU64(reader, TEXTURE_UPLOAD_PRIOR_REVISION, "prior revision", error) || + !expectedU64(reader, TEXTURE_UPLOAD_REVISION, "revision", error) || + !expectedU32(reader, 11, "old image index", error) || !expectedU32(reader, 1, "old image generation", error) || + !expectedU32(reader, 11, "replacement image index", error) || + !expectedU32(reader, 2, "replacement image generation", error) || + !expectedU32(reader, 0, "base mip", error) || !expectedU32(reader, 0, "array layer", error) || + !expectedU32(reader, 0, "upload x offset", error) || !expectedU32(reader, 0, "upload y offset", error) || + !expectedU32(reader, TEXTURE_UPLOAD_RESIDENT_WIDTH, "upload width", error) || + !expectedU32(reader, TEXTURE_UPLOAD_RESIDENT_HEIGHT, "upload height", error) || + !expectedU32(reader, TEXTURE_UPLOAD_RESIDENT_WIDTH, "resident width", error) || + !expectedU32(reader, TEXTURE_UPLOAD_RESIDENT_HEIGHT, "resident height", error) || + !expectedU32(reader, TEXTURE_UPLOAD_LOGICAL_WIDTH, "logical width", error) || + !expectedU32(reader, TEXTURE_UPLOAD_LOGICAL_HEIGHT, "logical height", error) || + !expectedU32(reader, TEXTURE_UPLOAD_RESIDENT_DISCARD, "resident discard", error) || + !expectedU32(reader, RGBA8_WIRE_VALUE, "source format", error) || + !expectedU32(reader, RGBA8_WIRE_VALUE, "destination format", error) || + !expectedU32(reader, TOP_LEFT_WIRE_VALUE, "source row origin", error) || + !expectedU32(reader, BOTTOM_LEFT_WIRE_VALUE, "artifact row origin", error) || + !expectedU32(reader, TEXTURE_UPLOAD_ROW_PITCH, "source row pitch", error) || + !expectedU32(reader, GENERATE_MIPS_WIRE_VALUE, "mip generation", error) || + !expectedU32(reader, UNDEFINED_WIRE_VALUE, "before state", error) || + !expectedU32(reader, TRANSFER_DEST_WIRE_VALUE, "during state", error) || + !expectedU32(reader, SHADER_READ_WIRE_VALUE, "after state", error) || + !expectedU32(reader, TEXTURE_UPLOAD_MIP_LEVELS, "mip count", error) || + !expectedU32(reader, TEXTURE_UPLOAD_OUTPUT_WIDTH, "sample width", error) || + !expectedU32(reader, TEXTURE_UPLOAD_OUTPUT_HEIGHT, "sample height", error) || + !expectedU32(reader, RGBA8_WIRE_VALUE, "sample format", error) || + !expectedU32(reader, TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT, "sample byte count", error) || + !expectedU32(reader, 1, "completion count", error) || !expectedU32(reader, 11, "completed image index", error) || + !expectedU32(reader, 2, "completed image generation", error) || + !expectedU64(reader, TEXTURE_UPLOAD_REVISION, "completed revision", error) || + !expectedU64(reader, TEXTURE_UPLOAD_DIAGNOSTIC_FRAME, "completed frame", error) || + !expectedU32(reader, 1, "retirement count", error) || !expectedU32(reader, 11, "retired image index", error) || + !expectedU32(reader, 1, "retired image generation", error) || + !expectedU64(reader, TEXTURE_UPLOAD_DIAGNOSTIC_FRAME, "retirement frame", error) || + !expectedU32(reader, 1, "old resolvable before", error) || !expectedU32(reader, 0, "old resolvable after", error) || + !expectedU32(reader, 1, "replacement resolvable after", error)) + { + return false; + } + + TextureUploadArtifact result = makeTextureUploadArtifact(); + result.mPriorRevision = TEXTURE_UPLOAD_PRIOR_REVISION; + result.mRevision = TEXTURE_UPLOAD_REVISION; + result.mCompletionCount = 1; + result.mCompletedDestination = ImageHandle{ 11, 2 }; + result.mCompletedRevision = TEXTURE_UPLOAD_REVISION; + result.mCompletedFrame = TEXTURE_UPLOAD_DIAGNOSTIC_FRAME; + result.mRetirementCount = 1; + result.mRetiredResource = ImageHandle{ 11, 1 }; + result.mRetirementFrame = TEXTURE_UPLOAD_DIAGNOSTIC_FRAME; + result.mOldResolvableBefore = true; + result.mOldResolvableAfter = false; + result.mReplacementResolvableAfter = true; + for (std::size_t mip = 0; mip < TEXTURE_UPLOAD_MIP_LEVELS; ++mip) + { + const std::string prefix = "mip " + std::to_string(mip) + ' '; + if (!expectedU32(reader, static_cast(mip), prefix + "level", error) || + !expectedU32(reader, MIP_WIDTHS[mip], prefix + "width", error) || + !expectedU32(reader, MIP_HEIGHTS[mip], prefix + "height", error) || + !expectedU32(reader, static_cast(TEXTURE_UPLOAD_MIP_BYTE_SIZES[mip]), prefix + "byte count", error)) + { + return false; + } + result.mMipRGBA8[mip].resize(TEXTURE_UPLOAD_MIP_BYTE_SIZES[mip]); + if (!reader.readBytes(result.mMipRGBA8[mip].data(), result.mMipRGBA8[mip].size())) + { + return fail(error, prefix + "payload is truncated"); + } + } + result.mSampledRGBA8.resize(TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT); + if (!reader.readBytes(result.mSampledRGBA8.data(), result.mSampledRGBA8.size())) + { + return fail(error, "sampled payload is truncated"); + } + std::uint64_t decoded_checksum = 0; + if (!reader.readU64(decoded_checksum) || decoded_checksum != stored_checksum) + { + return fail(error, "artifact checksum is inconsistent"); + } + if (reader.offset() != encoded.size()) + { + return fail(error, "artifact has trailing data"); + } + if (!validateTextureUploadArtifact(result, error)) + { + return false; + } + artifact = std::move(result); + return true; +} + +bool writeTextureUploadArtifact(const std::filesystem::path& destination, const TextureUploadArtifact& artifact, std::string* error) +{ + clearError(error); + if (destination.empty() || destination.filename().empty()) + { + return fail(error, "artifact destination must name a file"); + } + + std::vector encoded; + if (!encodeTextureUploadArtifact(artifact, encoded, error)) + { + return false; + } + + std::filesystem::path temporary; + std::FILE* output = nullptr; + int open_error = 0; + for (std::size_t attempt = 0; attempt < 64 && !output; ++attempt) + { + temporary = temporaryArtifactPath(destination); + output = openExclusive(temporary, open_error); + if (!output && open_error != EEXIST) + { + return fail(error, "cannot create artifact temporary file: " + + std::error_code(open_error, std::generic_category()).message()); + } + } + if (!output) + { + return fail(error, "cannot reserve a unique artifact temporary file"); + } + + const bool wrote = std::fwrite(encoded.data(), 1, encoded.size(), output) == encoded.size(); + const bool flushed = wrote && std::fflush(output) == 0; + const bool closed = std::fclose(output) == 0; + if (!wrote || !flushed || !closed) + { + std::error_code cleanup_error; + std::filesystem::remove(temporary, cleanup_error); + return fail(error, "cannot write artifact temporary file: " + temporary.string()); + } + + std::error_code file_error; + std::filesystem::create_hard_link(temporary, destination, file_error); + if (file_error) + { + std::error_code cleanup_error; + std::filesystem::remove(temporary, cleanup_error); + return fail(error, "cannot publish artifact: " + file_error.message()); + } + std::filesystem::remove(temporary, file_error); + if (file_error) + { + if (error) + { + *error = "artifact published but its temporary link could not be removed: " + file_error.message(); + } + return true; + } + return true; +} + +bool readTextureUploadArtifact(const std::filesystem::path& source, TextureUploadArtifact& artifact, std::string* error) +{ + clearError(error); + std::error_code file_error; + const std::uintmax_t size = std::filesystem::file_size(source, file_error); + if (file_error) + { + return fail(error, "cannot inspect artifact file: " + file_error.message()); + } + if (size != ARTIFACT_SIZE) + { + std::ostringstream message; + message << "artifact file has " << size << " bytes, expected " << ARTIFACT_SIZE; + return fail(error, message.str()); + } + + std::ifstream input(source, std::ios::binary | std::ios::in); + if (!input) + { + return fail(error, "cannot open artifact file: " + source.string()); + } + std::vector encoded(static_cast(size)); + input.read(reinterpret_cast(encoded.data()), static_cast(encoded.size())); + if (!input || input.peek() != std::ifstream::traits_type::eof()) + { + return fail(error, "cannot read complete artifact file: " + source.string()); + } + return decodeTextureUploadArtifact(encoded, artifact, error); +} + +TextureUploadComparisonStats compareTextureUploadArtifacts(const TextureUploadArtifact& reference, + const TextureUploadArtifact& candidate) +{ + TextureUploadComparisonStats stats; + std::string validation_error; + if (!validateTextureUploadArtifact(reference, &validation_error)) + { + stats.mError = "reference " + validation_error; + return stats; + } + if (!validateTextureUploadArtifact(candidate, &validation_error)) + { + stats.mError = "candidate " + validation_error; + return stats; + } + + stats.mComparable = true; + auto compare_plane = [&stats](const std::vector& left, const std::vector& right, + std::uint32_t plane) + { + for (std::size_t byte = 0; byte < left.size(); ++byte) + { + if (left[byte] == right[byte]) + { + continue; + } + ++stats.mMismatchCount; + if (stats.mFirstMismatchPlane == 0) + { + stats.mFirstMismatchPlane = plane; + stats.mFirstMismatchByte = byte; + stats.mFirstReference = left[byte]; + stats.mFirstCandidate = right[byte]; + } + } + }; + + for (std::size_t mip = 0; mip < TEXTURE_UPLOAD_MIP_LEVELS; ++mip) + { + compare_plane(reference.mMipRGBA8[mip], candidate.mMipRGBA8[mip], static_cast(mip + 1)); + stats.mComparedMipBytes += reference.mMipRGBA8[mip].size(); + } + compare_plane(reference.mSampledRGBA8, candidate.mSampledRGBA8, TEXTURE_UPLOAD_MIP_LEVELS + 1); + stats.mComparedSampleBytes = reference.mSampledRGBA8.size(); + stats.mMatch = stats.mMismatchCount == 0; + return stats; +} + +} // namespace LLRenderContract diff --git a/indra/llrender/lltextureuploaddiagnostic.h b/indra/llrender/lltextureuploaddiagnostic.h new file mode 100644 index 00000000000..76f7c15603d --- /dev/null +++ b/indra/llrender/lltextureuploaddiagnostic.h @@ -0,0 +1,130 @@ +/** + * @file lltextureuploaddiagnostic.h + * @brief Deterministic fixture and artifact for a streamed texture upload. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLTEXTUREUPLOADDIAGNOSTIC_H +#define LL_LLTEXTUREUPLOADDIAGNOSTIC_H + +#include "lltextureuploadcontract.h" + +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ + +inline constexpr std::uint32_t TEXTURE_UPLOAD_DIAGNOSTIC_FIXTURE_VERSION = 1; +inline constexpr std::uint32_t TEXTURE_UPLOAD_ARTIFACT_SCHEMA_VERSION = 2; +inline constexpr std::size_t TEXTURE_UPLOAD_ARTIFACT_BYTE_SIZE = 480; +inline constexpr std::uint64_t TEXTURE_UPLOAD_DIAGNOSTIC_FRAME = 1; + +struct TextureUploadFixture +{ + Extent2D mResidentExtent{ TEXTURE_UPLOAD_RESIDENT_WIDTH, TEXTURE_UPLOAD_RESIDENT_HEIGHT }; + Extent2D mLogicalExtent{ TEXTURE_UPLOAD_LOGICAL_WIDTH, TEXTURE_UPLOAD_LOGICAL_HEIGHT }; + Extent2D mOutputExtent{ TEXTURE_UPLOAD_OUTPUT_WIDTH, TEXTURE_UPLOAD_OUTPUT_HEIGHT }; + RowOrigin mSourceRowOrigin = RowOrigin::TopLeft; + std::uint32_t mSourceRowPitch = TEXTURE_UPLOAD_ROW_PITCH; + std::uint64_t mPriorRevision = TEXTURE_UPLOAD_PRIOR_REVISION; + + // Top-left row-major RGBA8 rows. Each row ends in four poison bytes. + std::array mSourceRGBA8{}; + + // Three Float3 positions with a fourth padding word for the 16-byte stride. + std::array mScreenTriangle{}; + + // Bottom-left row-major RGBA8 mips at TEXTURE_UPLOAD_MIP_BYTE_OFFSETS. + std::array mOldMipRGBA8{}; + std::array mReplacementSentinelMipRGBA8{}; + std::array mOutputSentinelRGBA8{}; +}; + +TextureUploadFixture makeTextureUploadFixture(); +std::uint64_t textureUploadFixtureFingerprint(); + +struct TextureUploadCase +{ + std::uint64_t mPriorRevision = TEXTURE_UPLOAD_PRIOR_REVISION; + StreamingUploadInputs mInputs; + FrameSnapshot mFrame; +}; + +TextureUploadCase makeTextureUploadCase(); + +struct TextureUploadArtifact +{ + std::uint64_t mPriorRevision = 0; + std::uint64_t mRevision = 0; + + // Bottom-left row-major RGBA8 bytes for the 8x4, 4x2, and 2x1 mips. + std::array, TEXTURE_UPLOAD_MIP_LEVELS> mMipRGBA8; + // Bottom-left row-major RGBA8 bytes for the 4x2 sampled output. + std::vector mSampledRGBA8; + + std::uint32_t mCompletionCount = 0; + ImageHandle mCompletedDestination{}; + std::uint64_t mCompletedRevision = 0; + std::uint64_t mCompletedFrame = 0; + + std::uint32_t mRetirementCount = 0; + ImageHandle mRetiredResource{}; + std::uint64_t mRetirementFrame = 0; + + bool mOldResolvableBefore = false; + bool mOldResolvableAfter = false; + bool mReplacementResolvableAfter = false; + + friend bool operator==(const TextureUploadArtifact&, const TextureUploadArtifact&) = default; +}; + +// Returns an unobserved artifact with empty pixel payloads and no lifetime evidence. +TextureUploadArtifact makeTextureUploadArtifact(); + +bool validateTextureUploadArtifact(const TextureUploadArtifact& artifact, std::string* error = nullptr); +bool encodeTextureUploadArtifact(const TextureUploadArtifact& artifact, std::vector& encoded, + std::string* error = nullptr); +bool decodeTextureUploadArtifact(const std::vector& encoded, TextureUploadArtifact& artifact, + std::string* error = nullptr); + +// The destination must not exist. The writer reserves an unpredictable sibling +// file, writes it completely, then publishes it atomically without replacement. +// A successful publication may return a nonempty warning if sibling cleanup fails. +bool writeTextureUploadArtifact(const std::filesystem::path& destination, const TextureUploadArtifact& artifact, + std::string* error = nullptr); +bool readTextureUploadArtifact(const std::filesystem::path& source, TextureUploadArtifact& artifact, + std::string* error = nullptr); + +struct TextureUploadComparisonStats +{ + bool mComparable = false; + bool mMatch = false; + std::size_t mComparedMipBytes = 0; + std::size_t mComparedSampleBytes = 0; + std::size_t mMismatchCount = 0; + std::uint32_t mFirstMismatchPlane = 0; + std::size_t mFirstMismatchByte = 0; + std::uint8_t mFirstReference = 0; + std::uint8_t mFirstCandidate = 0; + std::string mError; +}; + +TextureUploadComparisonStats compareTextureUploadArtifacts(const TextureUploadArtifact& reference, + const TextureUploadArtifact& candidate); + +} // namespace LLRenderContract + +#endif // LL_LLTEXTUREUPLOADDIAGNOSTIC_H diff --git a/indra/llrender/lltonemapcontract.cpp b/indra/llrender/lltonemapcontract.cpp new file mode 100644 index 00000000000..a0dba9a8187 --- /dev/null +++ b/indra/llrender/lltonemapcontract.cpp @@ -0,0 +1,302 @@ +/** + * @file lltonemapcontract.cpp + * @brief Pure builder and decoder for the viewer tonemap pass. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "lltonemapcontract.h" + +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + +constexpr const char* TONEMAP_PROGRAM = "deferred.tonemap"; +constexpr const char* TONEMAP_PASS = "tonemap"; + +bool sameExtent(Extent2D left, Extent2D right) +{ + return left.mWidth == right.mWidth && left.mHeight == right.mHeight; +} + +bool sameRange(const ImageSubresourceRange& left, const ImageSubresourceRange& right) +{ + return left.mBaseMipLevel == right.mBaseMipLevel && left.mMipLevelCount == right.mMipLevelCount && + left.mBaseArrayLayer == right.mBaseArrayLayer && left.mArrayLayerCount == right.mArrayLayerCount; +} + +bool sameSubresource(const ImageSubresource& left, const ImageSubresource& right) +{ + return left == right; +} + +bool finiteParameters(const TonemapParameters& parameters) +{ + return std::isfinite(parameters.mExposure) && parameters.mExposure >= 0.5f && parameters.mExposure <= 4.f && + std::isfinite(parameters.mTonemapMix) && std::isfinite(parameters.mGamma) && parameters.mGamma >= 0.f; +} + +bool validOutputFormat(PixelFormat format) +{ + return format == PixelFormat::RGBA8Unorm || format == PixelFormat::RGBA16Float; +} + +bool validInputs(const TonemapInputs& inputs) +{ + return inputs.mFrame != 0 && inputs.mHandles.mScreenTriangle && inputs.mHandles.mScene && inputs.mHandles.mExposure && + inputs.mHandles.mDestination && inputs.mHandles.mPointSampler && inputs.mHandles.mLinearSampler && inputs.mHandles.mPipeline && + inputs.mHandles.mPass && inputs.mSourceExtent.mWidth != 0 && inputs.mSourceExtent.mHeight != 0 && + inputs.mDestinationExtent.mWidth != 0 && inputs.mDestinationExtent.mHeight != 0 && + validOutputFormat(inputs.mDestinationFormat) && validTonemapVariant(inputs.mVariant) && finiteParameters(inputs.mParameters); +} + +ByteRange parameterBytes(const TonemapParameters& parameters) +{ + static_assert(std::is_trivially_copyable_v); + auto storage = std::make_shared>(sizeof(parameters)); + std::memcpy(storage->data(), ¶meters, sizeof(parameters)); + return { std::move(storage), 0, sizeof(parameters) }; +} + +ImageResource image(ImageHandle handle, Extent2D extent, PixelFormat format) +{ + ImageResource result; + result.mHandle = handle; + result.mExtent = extent; + result.mFormat = format; + return result; +} + +SamplerResource sampler(SamplerHandle handle, Filter filter) +{ + SamplerResource result; + result.mHandle = handle; + result.mMinFilter = filter; + result.mMagFilter = filter; + result.mMipFilter = MipFilter::Disabled; + result.mAddressU = AddressMode::Mirror; + result.mAddressV = AddressMode::Mirror; + return result; +} + +bool canonicalSampler(const SamplerResource& sampler_resource, SamplerHandle handle, Filter filter) +{ + return sampler_resource.mHandle == handle && sampler_resource.mMinFilter == filter && sampler_resource.mMagFilter == filter && + sampler_resource.mMipFilter == MipFilter::Disabled && sampler_resource.mAddressU == AddressMode::Mirror && + sampler_resource.mAddressV == AddressMode::Mirror && sampler_resource.mMaxAnisotropy == 1.f && + sampler_resource.mLifetime == ResourceLifetime::Persistent; +} + +bool canonicalImage(const ImageResource& image_resource, ImageHandle handle, Extent2D extent, PixelFormat format) +{ + return image_resource.mHandle == handle && sameExtent(image_resource.mExtent, extent) && image_resource.mMipLevels == 1 && + image_resource.mArrayLayers == 1 && image_resource.mSamples == 1 && image_resource.mFormat == format && + image_resource.mLifetime == ResourceLifetime::Persistent; +} + +bool canonicalSampledAccess(const ImageAccess& access, ImageHandle image_handle) +{ + return access.mImage == image_handle && sameRange(access.mRange, {}) && access.mKind == ImageAccessKind::SampledRead && + access.mBefore == ImageState::ShaderRead && access.mDuring == ImageState::ShaderRead && access.mAfter == ImageState::ShaderRead; +} + +bool canonicalDestinationAccess(const ImageAccess& access, ImageHandle image_handle) +{ + return access.mImage == image_handle && sameRange(access.mRange, {}) && access.mKind == ImageAccessKind::ColorAttachmentWrite && + access.mBefore == ImageState::Undefined && access.mDuring == ImageState::ColorAttachment && access.mAfter == ImageState::ShaderRead; +} + +bool canonicalSampledBinding(const SampledImageBinding& binding, std::uint32_t slot, ImageHandle image_handle, SamplerHandle sampler_handle) +{ + return binding.mBinding == slot && binding.mImage == image_handle && sameRange(binding.mRange, {}) && + binding.mSampler == sampler_handle; +} + +} + +bool validTonemapVariant(TonemapVariant variant) noexcept +{ + switch (variant) + { + case TonemapVariant::Deferred: + case TonemapVariant::NoPost: + case TonemapVariant::GammaCorrect: + case TonemapVariant::NoPostGammaCorrect: + case TonemapVariant::LegacyGammaCorrect: + case TonemapVariant::NoPostLegacyGammaCorrect: + return true; + } + return false; +} + +std::optional buildTonemapFrame(const TonemapInputs& inputs) +{ + if (!validInputs(inputs)) + { + return std::nullopt; + } + + const TonemapHandles& handles = inputs.mHandles; + + FrameSnapshot frame; + frame.mFrame = inputs.mFrame; + frame.mBuffers.push_back({ handles.mScreenTriangle, 48, ResourceLifetime::Persistent }); + frame.mImages = { image(handles.mScene, inputs.mSourceExtent, PixelFormat::RGBA16Float), + image(handles.mExposure, { 1, 1 }, PixelFormat::R16Float), + image(handles.mDestination, inputs.mDestinationExtent, inputs.mDestinationFormat) }; + frame.mSamplers = { sampler(handles.mPointSampler, Filter::Nearest), sampler(handles.mLinearSampler, Filter::Linear) }; + + PipelineResource pipeline; + pipeline.mHandle = handles.mPipeline; + pipeline.mProgram = { TONEMAP_PROGRAM, static_cast(inputs.mVariant) }; + pipeline.mCullMode = CullMode::Disabled; + pipeline.mDepthTestEnabled = false; + pipeline.mDepthWriteEnabled = false; + pipeline.mDepthCompare = CompareOp::LessOrEqual; + pipeline.mColorTargets.push_back({ inputs.mDestinationFormat, false, 0xf }); + pipeline.mVertexBindings.push_back({ 0, 16 }); + pipeline.mVertexAttributes.push_back({ VertexSemantic::Position, VertexFormat::Float3, 0, 0 }); + pipeline.mSampledImageBindings = { 0, 1 }; + pipeline.mParameterBindings.push_back({ 0, sizeof(TonemapParameters) }); + frame.mPipelines.push_back(std::move(pipeline)); + + RenderPass pass; + pass.mId = handles.mPass; + pass.mLabel = TONEMAP_PASS; + pass.mExtent = inputs.mDestinationExtent; + pass.mViewport = { 0.f, 0.f, static_cast(inputs.mDestinationExtent.mWidth), + static_cast(inputs.mDestinationExtent.mHeight), 0.f, 1.f }; + pass.mScissor = { 0, 0, inputs.mDestinationExtent.mWidth, inputs.mDestinationExtent.mHeight }; + pass.mBufferAccesses.push_back({ handles.mScreenTriangle, BufferAccessKind::VertexRead }); + pass.mImageAccesses = { + { handles.mScene, {}, ImageAccessKind::SampledRead, ImageState::ShaderRead, ImageState::ShaderRead, ImageState::ShaderRead }, + { handles.mExposure, {}, ImageAccessKind::SampledRead, ImageState::ShaderRead, ImageState::ShaderRead, ImageState::ShaderRead }, + { handles.mDestination, {}, ImageAccessKind::ColorAttachmentWrite, ImageState::Undefined, + ImageState::ColorAttachment, ImageState::ShaderRead } + }; + pass.mColorAttachments.push_back({ handles.mDestination, {}, LoadOp::DontCare, StoreOp::Store, {} }); + + Draw draw; + draw.mResources.mPipeline = handles.mPipeline; + draw.mResources.mVertexBuffers.push_back({ 0, handles.mScreenTriangle, 0 }); + draw.mResources.mSampledImages = { + { 0, handles.mScene, {}, handles.mPointSampler }, + { 1, handles.mExposure, {}, handles.mLinearSampler } + }; + draw.mResources.mParameters.push_back({ 0, parameterBytes(inputs.mParameters) }); + draw.mVertexCount = 3; + pass.mDraws.emplace_back(std::move(draw)); + frame.mPasses.push_back(std::move(pass)); + + return frame; +} + +std::optional decodeTonemapFrame(const FrameSnapshot& frame) +{ + if (!static_cast(validate(frame)) || !frame.mUploads.empty() || !frame.mReleases.empty() || frame.mBuffers.size() != 1 || + frame.mImages.size() != 3 || frame.mSamplers.size() != 2 || frame.mPipelines.size() != 1 || frame.mPasses.size() != 1) + { + return std::nullopt; + } + + const BufferResource& buffer = frame.mBuffers.front(); + const ImageResource& scene = frame.mImages[0]; + const ImageResource& exposure = frame.mImages[1]; + const ImageResource& destination = frame.mImages[2]; + const SamplerResource& point_sampler = frame.mSamplers[0]; + const SamplerResource& linear_sampler = frame.mSamplers[1]; + const PipelineResource& pipeline = frame.mPipelines.front(); + const RenderPass& pass = frame.mPasses.front(); + + TonemapInputs result; + result.mFrame = frame.mFrame; + result.mHandles = { buffer.mHandle, scene.mHandle, exposure.mHandle, destination.mHandle, + point_sampler.mHandle, linear_sampler.mHandle, pipeline.mHandle, pass.mId }; + result.mSourceExtent = scene.mExtent; + result.mDestinationExtent = destination.mExtent; + result.mDestinationFormat = destination.mFormat; + result.mVariant = static_cast(pipeline.mProgram.mVariant); + + if (buffer.mSize != 48 || buffer.mLifetime != ResourceLifetime::Persistent || + !canonicalImage(scene, result.mHandles.mScene, result.mSourceExtent, PixelFormat::RGBA16Float) || + !canonicalImage(exposure, result.mHandles.mExposure, { 1, 1 }, PixelFormat::R16Float) || + !canonicalImage(destination, result.mHandles.mDestination, result.mDestinationExtent, result.mDestinationFormat) || + !validOutputFormat(result.mDestinationFormat) || !canonicalSampler(point_sampler, result.mHandles.mPointSampler, Filter::Nearest) || + !canonicalSampler(linear_sampler, result.mHandles.mLinearSampler, Filter::Linear) || !validTonemapVariant(result.mVariant)) + { + return std::nullopt; + } + + if (pipeline.mProgram.mName != TONEMAP_PROGRAM || pipeline.mTopology != PrimitiveTopology::TriangleList || + pipeline.mCullMode != CullMode::Disabled || pipeline.mFrontFace != FrontFace::CounterClockwise || pipeline.mDepthTestEnabled || + pipeline.mDepthWriteEnabled || pipeline.mDepthCompare != CompareOp::LessOrEqual || pipeline.mSamples != 1 || pipeline.mDepthFormat || + pipeline.mLifetime != ResourceLifetime::Persistent || pipeline.mColorTargets.size() != 1 || + pipeline.mColorTargets[0].mFormat != result.mDestinationFormat || pipeline.mColorTargets[0].mBlendEnabled || + pipeline.mColorTargets[0].mWriteMask != 0xf || pipeline.mVertexBindings.size() != 1 || + pipeline.mVertexBindings[0].mBinding != 0 || pipeline.mVertexBindings[0].mStride != 16 || + pipeline.mVertexAttributes.size() != 1 || pipeline.mVertexAttributes[0].mSemantic != VertexSemantic::Position || + pipeline.mVertexAttributes[0].mFormat != VertexFormat::Float3 || pipeline.mVertexAttributes[0].mBinding != 0 || + pipeline.mVertexAttributes[0].mOffset != 0 || pipeline.mSampledImageBindings != std::vector{ 0, 1 } || + pipeline.mParameterBindings.size() != 1 || pipeline.mParameterBindings[0].mBinding != 0 || + pipeline.mParameterBindings[0].mSize != sizeof(TonemapParameters)) + { + return std::nullopt; + } + + if (pass.mId != result.mHandles.mPass || pass.mLabel != TONEMAP_PASS || !sameExtent(pass.mExtent, result.mDestinationExtent) || + pass.mViewport.mX != 0.f || pass.mViewport.mY != 0.f || pass.mViewport.mWidth != result.mDestinationExtent.mWidth || + pass.mViewport.mHeight != result.mDestinationExtent.mHeight || pass.mViewport.mMinDepth != 0.f || pass.mViewport.mMaxDepth != 1.f || + pass.mScissor.mX != 0 || pass.mScissor.mY != 0 || pass.mScissor.mWidth != result.mDestinationExtent.mWidth || + pass.mScissor.mHeight != result.mDestinationExtent.mHeight || !pass.mDependencies.empty() || pass.mBufferAccesses.size() != 1 || + pass.mBufferAccesses[0].mBuffer != result.mHandles.mScreenTriangle || + pass.mBufferAccesses[0].mKind != BufferAccessKind::VertexRead || pass.mImageAccesses.size() != 3 || + !canonicalSampledAccess(pass.mImageAccesses[0], result.mHandles.mScene) || + !canonicalSampledAccess(pass.mImageAccesses[1], result.mHandles.mExposure) || + !canonicalDestinationAccess(pass.mImageAccesses[2], result.mHandles.mDestination) || pass.mColorAttachments.size() != 1 || + pass.mColorAttachments[0].mImage != result.mHandles.mDestination || !sameSubresource(pass.mColorAttachments[0].mSubresource, {}) || + pass.mColorAttachments[0].mLoad != LoadOp::DontCare || pass.mColorAttachments[0].mStore != StoreOp::Store || + pass.mDepthAttachment || pass.mDraws.size() != 1 || !std::holds_alternative(pass.mDraws.front())) + { + return std::nullopt; + } + + const Draw& draw = std::get(pass.mDraws.front()); + if (draw.mResources.mPipeline != result.mHandles.mPipeline || draw.mResources.mVertexBuffers.size() != 1 || + draw.mResources.mVertexBuffers[0].mBinding != 0 || draw.mResources.mVertexBuffers[0].mBuffer != result.mHandles.mScreenTriangle || + draw.mResources.mVertexBuffers[0].mOffset != 0 || draw.mResources.mSampledImages.size() != 2 || + !canonicalSampledBinding(draw.mResources.mSampledImages[0], 0, result.mHandles.mScene, result.mHandles.mPointSampler) || + !canonicalSampledBinding(draw.mResources.mSampledImages[1], 1, result.mHandles.mExposure, result.mHandles.mLinearSampler) || + draw.mResources.mParameters.size() != 1 || draw.mResources.mParameters[0].mBinding != 0 || + draw.mResources.mParameters[0].mBytes.mSize != sizeof(TonemapParameters) || draw.mFirstVertex != 0 || draw.mVertexCount != 3 || + draw.mFirstInstance != 0 || draw.mInstanceCount != 1) + { + return std::nullopt; + } + + const ByteRange& bytes = draw.mResources.mParameters[0].mBytes; + std::memcpy(&result.mParameters, bytes.mStorage->data() + bytes.mOffset, sizeof(result.mParameters)); + if (!finiteParameters(result.mParameters)) + { + return std::nullopt; + } + + return result; +} + +} diff --git a/indra/llrender/lltonemapcontract.h b/indra/llrender/lltonemapcontract.h new file mode 100644 index 00000000000..047832ba75a --- /dev/null +++ b/indra/llrender/lltonemapcontract.h @@ -0,0 +1,89 @@ +/** + * @file lltonemapcontract.h + * @brief Backend-neutral description of the viewer tonemap pass. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLTONEMAPCONTRACT_H +#define LL_LLTONEMAPCONTRACT_H + +#include "llrendercontract.h" + +#include +#include +#include + +namespace LLRenderContract +{ + +enum class TonemapVariant : std::uint64_t +{ + Deferred = 0, + NoPost = 1, + GammaCorrect = 2, + NoPostGammaCorrect = 3, + LegacyGammaCorrect = 6, + NoPostLegacyGammaCorrect = 7 +}; + +struct TonemapParameters +{ + float mExposure = 1.f; + float mTonemapMix = 1.f; + std::uint32_t mTonemapType = 0; + float mGamma = 1.f; + + friend constexpr bool operator==(const TonemapParameters&, const TonemapParameters&) = default; +}; + +static_assert(sizeof(TonemapParameters) == 16, "tonemap parameters must remain a four-word packet"); +static_assert(offsetof(TonemapParameters, mExposure) == 0); +static_assert(offsetof(TonemapParameters, mTonemapMix) == 4); +static_assert(offsetof(TonemapParameters, mTonemapType) == 8); +static_assert(offsetof(TonemapParameters, mGamma) == 12); + +struct TonemapHandles +{ + BufferHandle mScreenTriangle{ 1, 1 }; + ImageHandle mScene{ 1, 1 }; + ImageHandle mExposure{ 2, 1 }; + ImageHandle mDestination{ 3, 1 }; + SamplerHandle mPointSampler{ 1, 1 }; + SamplerHandle mLinearSampler{ 2, 1 }; + PipelineHandle mPipeline{ 1, 1 }; + PassId mPass{ 1 }; + + friend constexpr bool operator==(const TonemapHandles&, const TonemapHandles&) = default; +}; + +struct TonemapInputs +{ + std::uint64_t mFrame = 0; + TonemapHandles mHandles; + Extent2D mSourceExtent; + Extent2D mDestinationExtent; + PixelFormat mDestinationFormat = PixelFormat::RGBA16Float; + TonemapVariant mVariant = TonemapVariant::Deferred; + TonemapParameters mParameters; +}; + +bool validTonemapVariant(TonemapVariant variant) noexcept; + +// Returns no packet when policy supplied an invalid or non-finite input. +std::optional buildTonemapFrame(const TonemapInputs& inputs); + +// Accepts only the canonical tonemap packet shape and returns owned values. +std::optional decodeTonemapFrame(const FrameSnapshot& frame); + +} + +#endif diff --git a/indra/llrender/lltonemapdiagnostic.cpp b/indra/llrender/lltonemapdiagnostic.cpp new file mode 100644 index 00000000000..d426b2bf5a9 --- /dev/null +++ b/indra/llrender/lltonemapdiagnostic.cpp @@ -0,0 +1,808 @@ +/** + * @file lltonemapdiagnostic.cpp + * @brief Shared fixture and artifact format for cross-process tonemap checks. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "lltonemapdiagnostic.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + +static_assert(sizeof(float) == sizeof(std::uint32_t), "tonemap artifacts require 32-bit floats"); +static_assert(std::numeric_limits::is_iec559, "tonemap artifacts require IEEE 754 floats"); + +constexpr std::array ARTIFACT_MAGIC{ 'L', 'L', 'T', 'O', 'N', 'E', 'M', 'P' }; +constexpr std::uint32_t BOTTOM_LEFT_WIRE_VALUE = 1; +constexpr std::uint32_t RGBA8_WIRE_VALUE = 1; +constexpr std::uint32_t RGBA16F_WIRE_VALUE = 2; +constexpr std::size_t ARTIFACT_HEADER_SIZE = 44; +constexpr std::size_t ARTIFACT_CASE_METADATA_SIZE = 44; +constexpr std::size_t ARTIFACT_CASE_SIZE = + ARTIFACT_CASE_METADATA_SIZE + TONEMAP_DIAGNOSTIC_COMPONENT_COUNT * sizeof(float); +constexpr std::size_t ARTIFACT_SIZE = + ARTIFACT_HEADER_SIZE + TONEMAP_DIAGNOSTIC_CASE_COUNT * ARTIFACT_CASE_SIZE; + +constexpr std::array TONEMAP_VARIANTS{ + TonemapVariant::Deferred, + TonemapVariant::NoPost, + TonemapVariant::GammaCorrect, + TonemapVariant::NoPostGammaCorrect, + TonemapVariant::LegacyGammaCorrect, + TonemapVariant::NoPostLegacyGammaCorrect +}; + +constexpr std::array TONEMAP_FORMATS{ + PixelFormat::RGBA8Unorm, + PixelFormat::RGBA16Float +}; + +constexpr TonemapParameters TONEMAP_PARAMETERS{ 1.25f, 0.65f, 0, 1.8f }; + +std::uint32_t floatBits(float value) noexcept +{ + std::uint32_t bits = 0; + std::memcpy(&bits, &value, sizeof(bits)); + return bits; +} + +float bitsToFloat(std::uint32_t bits) noexcept +{ + float value = 0.f; + std::memcpy(&value, &bits, sizeof(value)); + return value; +} + +void clearError(std::string* error) +{ + if (error) + { + error->clear(); + } +} + +bool fail(std::string* error, std::string message) +{ + if (error) + { + *error = std::move(message); + } + return false; +} + +std::string caseField(std::uint32_t case_index, const char* field) +{ + std::ostringstream out; + out << "case " << case_index << ' ' << field; + return out.str(); +} + +TonemapInputs canonicalInputs(std::size_t offset) +{ + const std::size_t cases_per_format = TONEMAP_VARIANTS.size() * 2; + const std::size_t format_index = offset / cases_per_format; + const std::size_t within_format = offset % cases_per_format; + const std::size_t variant_index = within_format / 2; + const std::uint32_t tonemap_type = static_cast(within_format % 2); + + TonemapInputs inputs; + inputs.mFrame = offset + 1; + inputs.mSourceExtent = { TONEMAP_DIAGNOSTIC_WIDTH, TONEMAP_DIAGNOSTIC_HEIGHT }; + inputs.mDestinationExtent = inputs.mSourceExtent; + inputs.mDestinationFormat = TONEMAP_FORMATS[format_index]; + inputs.mVariant = TONEMAP_VARIANTS[variant_index]; + inputs.mParameters = TONEMAP_PARAMETERS; + inputs.mParameters.mTonemapType = tonemap_type; + return inputs; +} + +TonemapCaseKey canonicalKey(std::size_t offset) +{ + const TonemapInputs inputs = canonicalInputs(offset); + return { static_cast(offset + 1), inputs.mDestinationFormat, inputs.mVariant, + inputs.mParameters.mTonemapType }; +} + +bool sameKey(const TonemapCaseKey& left, const TonemapCaseKey& right) +{ + return left == right; +} + +std::uint32_t formatWireValue(PixelFormat format) +{ + return format == PixelFormat::RGBA8Unorm ? RGBA8_WIRE_VALUE : RGBA16F_WIRE_VALUE; +} + +bool canonicalPixel(float value, PixelFormat format) +{ + if (!std::isfinite(value)) + { + return false; + } + + if (format == PixelFormat::RGBA8Unorm) + { + if (value < 0.f || value > 1.f) + { + return false; + } + const int code = static_cast(std::lround(value * 255.f)); + return value == static_cast(code) / 255.f; + } + + return format == PixelFormat::RGBA16Float && halfBitsToFloat(floatToHalfBits(value)) == value; +} + +void appendU32(std::vector& bytes, std::uint32_t value) +{ + bytes.push_back(static_cast(value >> 24)); + bytes.push_back(static_cast(value >> 16)); + bytes.push_back(static_cast(value >> 8)); + bytes.push_back(static_cast(value)); +} + +void appendU64(std::vector& bytes, std::uint64_t value) +{ + appendU32(bytes, static_cast(value >> 32)); + appendU32(bytes, static_cast(value)); +} + +void appendFloat(std::vector& bytes, float value) +{ + // Positive zero is the one canonical wire representation of zero. + appendU32(bytes, floatBits(value == 0.f ? 0.f : value)); +} + +class ArtifactReader +{ +public: + explicit ArtifactReader(const std::vector& bytes) : mBytes(bytes) {} + + bool readU32(std::uint32_t& value) + { + if (mOffset > mBytes.size() || mBytes.size() - mOffset < sizeof(value)) + { + return false; + } + value = (static_cast(mBytes[mOffset]) << 24) | + (static_cast(mBytes[mOffset + 1]) << 16) | + (static_cast(mBytes[mOffset + 2]) << 8) | + static_cast(mBytes[mOffset + 3]); + mOffset += sizeof(value); + return true; + } + + bool readU64(std::uint64_t& value) + { + std::uint32_t high = 0; + std::uint32_t low = 0; + if (!readU32(high) || !readU32(low)) + { + return false; + } + value = (static_cast(high) << 32) | low; + return true; + } + + bool readBytes(std::uint8_t* destination, std::size_t size) + { + if (mOffset > mBytes.size() || mBytes.size() - mOffset < size) + { + return false; + } + std::copy_n(mBytes.data() + mOffset, size, destination); + mOffset += size; + return true; + } + + std::size_t offset() const noexcept { return mOffset; } + +private: + const std::vector& mBytes; + std::size_t mOffset = 0; +}; + +void hashByte(std::uint64_t& hash, std::uint8_t value) +{ + constexpr std::uint64_t FNV_PRIME = 1099511628211ULL; + hash ^= value; + hash *= FNV_PRIME; +} + +void hashU16(std::uint64_t& hash, std::uint16_t value) +{ + hashByte(hash, static_cast(value >> 8)); + hashByte(hash, static_cast(value)); +} + +void hashU32(std::uint64_t& hash, std::uint32_t value) +{ + hashByte(hash, static_cast(value >> 24)); + hashByte(hash, static_cast(value >> 16)); + hashByte(hash, static_cast(value >> 8)); + hashByte(hash, static_cast(value)); +} + +bool expectedU32(ArtifactReader& reader, std::uint32_t expected, const std::string& field, std::string* error) +{ + std::uint32_t actual = 0; + if (!reader.readU32(actual)) + { + return fail(error, "artifact ends before " + field); + } + if (actual != expected) + { + std::ostringstream message; + message << field << " is " << actual << ", expected " << expected; + return fail(error, message.str()); + } + return true; +} + +bool expectedU64(ArtifactReader& reader, std::uint64_t expected, const std::string& field, std::string* error) +{ + std::uint64_t actual = 0; + if (!reader.readU64(actual)) + { + return fail(error, "artifact ends before " + field); + } + if (actual != expected) + { + std::ostringstream message; + message << field << " is " << actual << ", expected " << expected; + return fail(error, message.str()); + } + return true; +} + +bool expectedFloat(ArtifactReader& reader, float expected, const std::string& field, std::string* error) +{ + return expectedU32(reader, floatBits(expected), field, error); +} + +std::filesystem::path temporaryArtifactPath(const std::filesystem::path& destination) +{ + static std::atomic serial{ 0 }; + const auto now = std::chrono::steady_clock::now().time_since_epoch().count(); + const std::uint64_t sequence = serial.fetch_add(1, std::memory_order_relaxed); + const std::uint64_t token = static_cast(now) ^ + (sequence + 0x9e3779b97f4a7c15ULL + (sequence << 6) + (sequence >> 2)); + std::ostringstream suffix; + suffix << ".tmp." << std::hex << token; + std::filesystem::path temporary = destination; + temporary += suffix.str(); + return temporary; +} + +std::FILE* openExclusive(const std::filesystem::path& path, int& open_error) +{ +#if defined(_WIN32) + std::FILE* file = nullptr; + open_error = _wfopen_s(&file, path.c_str(), L"wbx"); + return file; +#else + errno = 0; + std::FILE* file = std::fopen(path.c_str(), "wbx"); + open_error = errno; + return file; +#endif +} + +} + +std::uint16_t floatToHalfBits(float value) noexcept +{ + const std::uint32_t bits = floatBits(value); + const std::uint16_t sign = static_cast((bits >> 16) & 0x8000U); + const std::uint32_t exponent = (bits >> 23) & 0xffU; + const std::uint32_t significand = bits & 0x7fffffU; + + if (exponent == 0xffU) + { + if (significand == 0) + { + return static_cast(sign | 0x7c00U); + } + std::uint16_t payload = static_cast(significand >> 13); + payload = static_cast(payload | 0x0200U); + return static_cast(sign | 0x7c00U | payload); + } + + const int half_exponent = static_cast(exponent) - 127 + 15; + if (half_exponent >= 31) + { + return static_cast(sign | 0x7c00U); + } + + if (half_exponent <= 0) + { + if (half_exponent < -10) + { + return sign; + } + + const std::uint32_t normalized = significand | 0x800000U; + const unsigned shift = static_cast(14 - half_exponent); + std::uint32_t rounded = normalized >> shift; + const std::uint32_t remainder = normalized & ((1U << shift) - 1U); + const std::uint32_t halfway = 1U << (shift - 1U); + if (remainder > halfway || (remainder == halfway && (rounded & 1U) != 0)) + { + ++rounded; + } + return static_cast(sign | rounded); + } + + std::uint32_t rounded = (static_cast(half_exponent) << 10) | (significand >> 13); + const std::uint32_t remainder = significand & 0x1fffU; + if (remainder > 0x1000U || (remainder == 0x1000U && (rounded & 1U) != 0)) + { + ++rounded; + } + return static_cast(sign | rounded); +} + +float halfBitsToFloat(std::uint16_t bits) noexcept +{ + const std::uint32_t sign = static_cast(bits & 0x8000U) << 16; + std::uint32_t exponent = (bits >> 10) & 0x1fU; + std::uint32_t significand = bits & 0x03ffU; + std::uint32_t result = 0; + + if (exponent == 0) + { + if (significand == 0) + { + result = sign; + } + else + { + exponent = 113; + while ((significand & 0x0400U) == 0) + { + significand <<= 1; + --exponent; + } + significand &= 0x03ffU; + result = sign | (exponent << 23) | (significand << 13); + } + } + else if (exponent == 0x1fU) + { + result = sign | 0x7f800000U | (significand << 13); + } + else + { + result = sign | ((exponent + 112U) << 23) | (significand << 13); + } + return bitsToFloat(result); +} + +TonemapFixture makeTonemapFixture() +{ + constexpr std::array LEVELS{ 0.f, 0.04f, 0.08f, 0.2f, 0.76f, 1.f, 2.f, 8.f }; + + TonemapFixture fixture; + fixture.mScreenTriangle = { + -1.f, 1.f, 0.f, 0.f, + -1.f, -3.f, 0.f, 0.f, + 3.f, 1.f, 0.f, 0.f + }; + + for (std::size_t pixel = 0; pixel < TONEMAP_DIAGNOSTIC_PIXEL_COUNT; ++pixel) + { + fixture.mSceneRGBA16F[pixel * 4] = floatToHalfBits(LEVELS[pixel % LEVELS.size()]); + fixture.mSceneRGBA16F[pixel * 4 + 1] = floatToHalfBits(LEVELS[(pixel * 3 + 1) % LEVELS.size()] * 0.75f); + fixture.mSceneRGBA16F[pixel * 4 + 2] = floatToHalfBits(LEVELS[(pixel * 5 + 2) % LEVELS.size()] * 1.25f); + fixture.mSceneRGBA16F[pixel * 4 + 3] = floatToHalfBits(static_cast(pixel % 7) / 6.f); + } + fixture.mExposureR16F = floatToHalfBits(0.85f); + return fixture; +} + +std::uint64_t tonemapFixtureFingerprint() +{ + constexpr std::uint64_t FNV_OFFSET_BASIS = 14695981039346656037ULL; + const TonemapFixture fixture = makeTonemapFixture(); + std::uint64_t hash = FNV_OFFSET_BASIS; + hashU32(hash, TONEMAP_DIAGNOSTIC_FIXTURE_VERSION); + hashU32(hash, fixture.mExtent.mWidth); + hashU32(hash, fixture.mExtent.mHeight); + hashU32(hash, BOTTOM_LEFT_WIRE_VALUE); + for (float component : fixture.mScreenTriangle) + { + hashU32(hash, floatBits(component)); + } + for (std::uint16_t component : fixture.mSceneRGBA16F) + { + hashU16(hash, component); + } + hashU16(hash, fixture.mExposureR16F); + return hash; +} + +TonemapCases makeTonemapCases() +{ + TonemapCases cases{}; + for (std::size_t offset = 0; offset < cases.size(); ++offset) + { + TonemapCase& diagnostic_case = cases[offset]; + diagnostic_case.mKey = canonicalKey(offset); + diagnostic_case.mInputs = canonicalInputs(offset); + auto frame = buildTonemapFrame(diagnostic_case.mInputs); + if (!frame) + { + std::terminate(); + } + diagnostic_case.mFrame = std::move(*frame); + } + return cases; +} + +TonemapArtifact makeTonemapArtifact() +{ + TonemapArtifact artifact; + artifact.mCases.reserve(TONEMAP_DIAGNOSTIC_CASE_COUNT); + for (std::size_t offset = 0; offset < TONEMAP_DIAGNOSTIC_CASE_COUNT; ++offset) + { + artifact.mCases.push_back({ canonicalKey(offset), {} }); + } + return artifact; +} + +bool validateTonemapArtifact(const TonemapArtifact& artifact, std::string* error) +{ + clearError(error); + if (artifact.mCases.size() != TONEMAP_DIAGNOSTIC_CASE_COUNT) + { + std::ostringstream message; + message << "artifact has " << artifact.mCases.size() << " cases, expected " << TONEMAP_DIAGNOSTIC_CASE_COUNT; + return fail(error, message.str()); + } + + for (std::size_t offset = 0; offset < artifact.mCases.size(); ++offset) + { + const TonemapArtifactCase& artifact_case = artifact.mCases[offset]; + const TonemapCaseKey expected = canonicalKey(offset); + if (!sameKey(artifact_case.mKey, expected)) + { + return fail(error, caseField(expected.mIndex, "metadata is not canonical")); + } + if (artifact_case.mPixels.size() != TONEMAP_DIAGNOSTIC_COMPONENT_COUNT) + { + std::ostringstream message; + message << "case " << expected.mIndex << " has " << artifact_case.mPixels.size() + << " components, expected " << TONEMAP_DIAGNOSTIC_COMPONENT_COUNT; + return fail(error, message.str()); + } + for (std::size_t component = 0; component < artifact_case.mPixels.size(); ++component) + { + if (!canonicalPixel(artifact_case.mPixels[component], expected.mDestinationFormat)) + { + std::ostringstream message; + message << "case " << expected.mIndex << " component " << component + << " is not a finite value representable by its destination format"; + return fail(error, message.str()); + } + } + } + return true; +} + +bool encodeTonemapArtifact(const TonemapArtifact& artifact, + std::vector& encoded, + std::string* error) +{ + clearError(error); + if (!validateTonemapArtifact(artifact, error)) + { + return false; + } + + std::vector result; + result.reserve(ARTIFACT_SIZE); + result.insert(result.end(), ARTIFACT_MAGIC.begin(), ARTIFACT_MAGIC.end()); + appendU32(result, TONEMAP_ARTIFACT_SCHEMA_VERSION); + appendU32(result, TONEMAP_DIAGNOSTIC_FIXTURE_VERSION); + appendU64(result, tonemapFixtureFingerprint()); + appendU32(result, TONEMAP_DIAGNOSTIC_WIDTH); + appendU32(result, TONEMAP_DIAGNOSTIC_HEIGHT); + appendU32(result, TONEMAP_DIAGNOSTIC_CHANNELS); + appendU32(result, BOTTOM_LEFT_WIRE_VALUE); + appendU32(result, static_cast(TONEMAP_DIAGNOSTIC_CASE_COUNT)); + + for (std::size_t offset = 0; offset < artifact.mCases.size(); ++offset) + { + const TonemapArtifactCase& artifact_case = artifact.mCases[offset]; + const TonemapInputs inputs = canonicalInputs(offset); + appendU32(result, artifact_case.mKey.mIndex); + appendU64(result, inputs.mFrame); + appendU32(result, formatWireValue(inputs.mDestinationFormat)); + appendU64(result, static_cast(inputs.mVariant)); + appendFloat(result, inputs.mParameters.mExposure); + appendFloat(result, inputs.mParameters.mTonemapMix); + appendU32(result, inputs.mParameters.mTonemapType); + appendFloat(result, inputs.mParameters.mGamma); + appendU32(result, static_cast(artifact_case.mPixels.size())); + for (float pixel : artifact_case.mPixels) + { + appendFloat(result, pixel); + } + } + + if (result.size() != ARTIFACT_SIZE) + { + return fail(error, "artifact encoder produced an unexpected byte count"); + } + encoded = std::move(result); + return true; +} + +bool decodeTonemapArtifact(const std::vector& encoded, + TonemapArtifact& artifact, + std::string* error) +{ + clearError(error); + if (encoded.size() != ARTIFACT_SIZE) + { + std::ostringstream message; + message << "artifact has " << encoded.size() << " bytes, expected " << ARTIFACT_SIZE; + return fail(error, message.str()); + } + + ArtifactReader reader(encoded); + std::array magic{}; + if (!reader.readBytes(magic.data(), magic.size()) || magic != ARTIFACT_MAGIC) + { + return fail(error, "artifact magic is invalid"); + } + if (!expectedU32(reader, TONEMAP_ARTIFACT_SCHEMA_VERSION, "schema version", error) || + !expectedU32(reader, TONEMAP_DIAGNOSTIC_FIXTURE_VERSION, "fixture version", error) || + !expectedU64(reader, tonemapFixtureFingerprint(), "fixture fingerprint", error) || + !expectedU32(reader, TONEMAP_DIAGNOSTIC_WIDTH, "width", error) || + !expectedU32(reader, TONEMAP_DIAGNOSTIC_HEIGHT, "height", error) || + !expectedU32(reader, TONEMAP_DIAGNOSTIC_CHANNELS, "channel count", error) || + !expectedU32(reader, BOTTOM_LEFT_WIRE_VALUE, "row origin", error) || + !expectedU32(reader, static_cast(TONEMAP_DIAGNOSTIC_CASE_COUNT), "case count", error)) + { + return false; + } + + TonemapArtifact result = makeTonemapArtifact(); + for (std::size_t offset = 0; offset < result.mCases.size(); ++offset) + { + const TonemapInputs inputs = canonicalInputs(offset); + const TonemapCaseKey key = canonicalKey(offset); + const std::string prefix = "case " + std::to_string(key.mIndex) + ' '; + if (!expectedU32(reader, key.mIndex, prefix + "index", error) || + !expectedU64(reader, inputs.mFrame, prefix + "frame", error) || + !expectedU32(reader, formatWireValue(inputs.mDestinationFormat), prefix + "format", error) || + !expectedU64(reader, static_cast(inputs.mVariant), prefix + "variant", error) || + !expectedFloat(reader, inputs.mParameters.mExposure, prefix + "exposure", error) || + !expectedFloat(reader, inputs.mParameters.mTonemapMix, prefix + "tonemap mix", error) || + !expectedU32(reader, inputs.mParameters.mTonemapType, prefix + "tonemap type", error) || + !expectedFloat(reader, inputs.mParameters.mGamma, prefix + "gamma", error) || + !expectedU32(reader, static_cast(TONEMAP_DIAGNOSTIC_COMPONENT_COUNT), prefix + "component count", error)) + { + return false; + } + + std::vector& pixels = result.mCases[offset].mPixels; + pixels.reserve(TONEMAP_DIAGNOSTIC_COMPONENT_COUNT); + for (std::size_t component = 0; component < TONEMAP_DIAGNOSTIC_COMPONENT_COUNT; ++component) + { + std::uint32_t bits = 0; + if (!reader.readU32(bits)) + { + return fail(error, prefix + "pixel payload is truncated"); + } + if (bits == 0x80000000U) + { + return fail(error, prefix + "pixel payload contains non-canonical negative zero"); + } + const float value = bitsToFloat(bits); + if (!canonicalPixel(value, inputs.mDestinationFormat)) + { + std::ostringstream message; + message << prefix << "component " << component + << " is not a finite value representable by its destination format"; + return fail(error, message.str()); + } + pixels.push_back(value); + } + } + + if (reader.offset() != encoded.size()) + { + return fail(error, "artifact has trailing data"); + } + artifact = std::move(result); + return true; +} + +bool writeTonemapArtifact(const std::filesystem::path& destination, + const TonemapArtifact& artifact, + std::string* error) +{ + clearError(error); + if (destination.empty() || destination.filename().empty()) + { + return fail(error, "artifact destination must name a file"); + } + + std::vector encoded; + if (!encodeTonemapArtifact(artifact, encoded, error)) + { + return false; + } + + std::filesystem::path temporary; + std::FILE* output = nullptr; + int open_error = 0; + for (std::size_t attempt = 0; attempt < 64 && !output; ++attempt) + { + temporary = temporaryArtifactPath(destination); + output = openExclusive(temporary, open_error); + if (!output && open_error != EEXIST) + { + return fail(error, "cannot create artifact temporary file: " + + std::error_code(open_error, std::generic_category()).message()); + } + } + if (!output) + { + return fail(error, "cannot reserve a unique artifact temporary file"); + } + + const bool wrote = std::fwrite(encoded.data(), 1, encoded.size(), output) == encoded.size(); + const bool flushed = wrote && std::fflush(output) == 0; + const bool closed = std::fclose(output) == 0; + if (!wrote || !flushed || !closed) + { + std::error_code cleanup_error; + std::filesystem::remove(temporary, cleanup_error); + return fail(error, "cannot write artifact temporary file: " + temporary.string()); + } + + // A same-directory hard link publishes the fully written bytes atomically + // and fails if another writer created the destination first. + std::error_code file_error; + std::filesystem::create_hard_link(temporary, destination, file_error); + if (file_error) + { + std::error_code cleanup_error; + std::filesystem::remove(temporary, cleanup_error); + return fail(error, "cannot publish artifact: " + file_error.message()); + } + std::filesystem::remove(temporary, file_error); + if (file_error) + { + return fail(error, "artifact published but its temporary link could not be removed: " + + file_error.message()); + } + return true; +} + +bool readTonemapArtifact(const std::filesystem::path& source, + TonemapArtifact& artifact, + std::string* error) +{ + clearError(error); + std::error_code file_error; + const std::uintmax_t size = std::filesystem::file_size(source, file_error); + if (file_error) + { + return fail(error, "cannot inspect artifact file: " + file_error.message()); + } + if (size != ARTIFACT_SIZE) + { + std::ostringstream message; + message << "artifact file has " << size << " bytes, expected " << ARTIFACT_SIZE; + return fail(error, message.str()); + } + + std::ifstream input(source, std::ios::binary | std::ios::in); + if (!input) + { + return fail(error, "cannot open artifact file: " + source.string()); + } + std::vector encoded(static_cast(size)); + input.read(reinterpret_cast(encoded.data()), static_cast(encoded.size())); + if (!input || input.peek() != std::ifstream::traits_type::eof()) + { + return fail(error, "cannot read complete artifact file: " + source.string()); + } + return decodeTonemapArtifact(encoded, artifact, error); +} + +float tonemapComparisonTolerance(PixelFormat format) noexcept +{ + switch (format) + { + case PixelFormat::RGBA8Unorm: + return TONEMAP_RGBA8_TOLERANCE; + case PixelFormat::RGBA16Float: + return TONEMAP_RGBA16F_TOLERANCE; + default: + return 0.f; + } +} + +TonemapComparisonStats compareTonemapArtifacts(const TonemapArtifact& reference, + const TonemapArtifact& candidate) +{ + TonemapComparisonStats stats; + std::string validation_error; + if (!validateTonemapArtifact(reference, &validation_error)) + { + stats.mError = "reference " + validation_error; + return stats; + } + if (!validateTonemapArtifact(candidate, &validation_error)) + { + stats.mError = "candidate " + validation_error; + return stats; + } + + stats.mComparable = true; + stats.mComparedCases = reference.mCases.size(); + for (std::size_t case_offset = 0; case_offset < reference.mCases.size(); ++case_offset) + { + const TonemapArtifactCase& reference_case = reference.mCases[case_offset]; + const TonemapArtifactCase& candidate_case = candidate.mCases[case_offset]; + const float tolerance = tonemapComparisonTolerance(reference_case.mKey.mDestinationFormat); + for (std::size_t component = 0; component < reference_case.mPixels.size(); ++component) + { + const float reference_value = reference_case.mPixels[component]; + const float candidate_value = candidate_case.mPixels[component]; + const double delta = std::fabs(static_cast(reference_value) - candidate_value); + stats.mMaximumAbsoluteError = std::max(stats.mMaximumAbsoluteError, delta); + ++stats.mComparedComponents; + if (delta <= tolerance) + { + continue; + } + + ++stats.mMismatchCount; + if (stats.mFirstMismatchCase == 0) + { + stats.mFirstMismatchCase = reference_case.mKey.mIndex; + stats.mFirstMismatchPixel = component / TONEMAP_DIAGNOSTIC_CHANNELS; + stats.mFirstMismatchChannel = static_cast(component % TONEMAP_DIAGNOSTIC_CHANNELS); + stats.mFirstReference = reference_value; + stats.mFirstCandidate = candidate_value; + stats.mFirstTolerance = tolerance; + } + } + } + stats.mMatch = stats.mMismatchCount == 0; + return stats; +} + +} diff --git a/indra/llrender/lltonemapdiagnostic.h b/indra/llrender/lltonemapdiagnostic.h new file mode 100644 index 00000000000..24ba1e8c54d --- /dev/null +++ b/indra/llrender/lltonemapdiagnostic.h @@ -0,0 +1,152 @@ +/** + * @file lltonemapdiagnostic.h + * @brief Shared fixture and artifact format for cross-process tonemap checks. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLTONEMAPDIAGNOSTIC_H +#define LL_LLTONEMAPDIAGNOSTIC_H + +#include "lltonemapcontract.h" + +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ + +inline constexpr std::uint32_t TONEMAP_DIAGNOSTIC_WIDTH = 8; +inline constexpr std::uint32_t TONEMAP_DIAGNOSTIC_HEIGHT = 8; +inline constexpr std::uint32_t TONEMAP_DIAGNOSTIC_CHANNELS = 4; +inline constexpr std::size_t TONEMAP_DIAGNOSTIC_PIXEL_COUNT = + static_cast(TONEMAP_DIAGNOSTIC_WIDTH) * TONEMAP_DIAGNOSTIC_HEIGHT; +inline constexpr std::size_t TONEMAP_DIAGNOSTIC_COMPONENT_COUNT = + TONEMAP_DIAGNOSTIC_PIXEL_COUNT * TONEMAP_DIAGNOSTIC_CHANNELS; +inline constexpr std::size_t TONEMAP_DIAGNOSTIC_CASE_COUNT = 24; + +inline constexpr std::uint32_t TONEMAP_DIAGNOSTIC_FIXTURE_VERSION = 1; +inline constexpr std::uint32_t TONEMAP_ARTIFACT_SCHEMA_VERSION = 1; + +inline constexpr float TONEMAP_RGBA8_TOLERANCE = 1.f / 255.f; +inline constexpr float TONEMAP_RGBA16F_TOLERANCE = 2.f / 1024.f; + +// These conversions use IEEE 754 round-to-nearest-even semantics. +std::uint16_t floatToHalfBits(float value) noexcept; +float halfBitsToFloat(std::uint16_t bits) noexcept; + +struct TonemapFixture +{ + Extent2D mExtent{ TONEMAP_DIAGNOSTIC_WIDTH, TONEMAP_DIAGNOSTIC_HEIGHT }; + RowOrigin mRowOrigin = RowOrigin::BottomLeft; + // Native uint16_t words containing IEEE binary16 bits, in bottom-left + // row-major RGBA order. + std::array mSceneRGBA16F{}; + std::uint16_t mExposureR16F = 0; + + // Three Float3 vertices with a fourth padding word for the contract's + // 16-byte vertex stride. + std::array mScreenTriangle{}; +}; + +static_assert(sizeof(std::array) == 48, "the diagnostic screen triangle must occupy 48 bytes"); + +struct TonemapCaseKey +{ + std::uint32_t mIndex = 0; + PixelFormat mDestinationFormat = PixelFormat::RGBA8Unorm; + TonemapVariant mVariant = TonemapVariant::Deferred; + std::uint32_t mTonemapType = 0; + + friend constexpr bool operator==(const TonemapCaseKey&, const TonemapCaseKey&) = default; +}; + +struct TonemapCase +{ + TonemapCaseKey mKey; + TonemapInputs mInputs; + FrameSnapshot mFrame; +}; + +using TonemapCases = std::array; + +TonemapFixture makeTonemapFixture(); +std::uint64_t tonemapFixtureFingerprint(); +TonemapCases makeTonemapCases(); + +struct TonemapArtifactCase +{ + TonemapCaseKey mKey; + // Bottom-left row-major RGBA values, one float per component. + std::vector mPixels; + + friend bool operator==(const TonemapArtifactCase&, const TonemapArtifactCase&) = default; +}; + +struct TonemapArtifact +{ + std::vector mCases; + + friend bool operator==(const TonemapArtifact&, const TonemapArtifact&) = default; +}; + +// Returns the canonical case metadata with empty pixel arrays. +TonemapArtifact makeTonemapArtifact(); + +bool validateTonemapArtifact(const TonemapArtifact& artifact, std::string* error = nullptr); +// The wire format uses big-endian integer and IEEE float words. Its fixed +// schema records the fixture identity and all canonical case metadata. +bool encodeTonemapArtifact(const TonemapArtifact& artifact, + std::vector& encoded, + std::string* error = nullptr); +bool decodeTonemapArtifact(const std::vector& encoded, + TonemapArtifact& artifact, + std::string* error = nullptr); + +// The destination must not exist. The writer creates a unique sibling file +// exclusively, then publishes the complete bytes atomically without replacement. +bool writeTonemapArtifact(const std::filesystem::path& destination, + const TonemapArtifact& artifact, + std::string* error = nullptr); +bool readTonemapArtifact(const std::filesystem::path& source, + TonemapArtifact& artifact, + std::string* error = nullptr); + +struct TonemapComparisonStats +{ + bool mComparable = false; + bool mMatch = false; + std::size_t mComparedCases = 0; + std::size_t mComparedComponents = 0; + std::size_t mMismatchCount = 0; + double mMaximumAbsoluteError = 0.0; + + std::uint32_t mFirstMismatchCase = 0; + std::size_t mFirstMismatchPixel = 0; + std::uint32_t mFirstMismatchChannel = 0; + float mFirstReference = 0.f; + float mFirstCandidate = 0.f; + float mFirstTolerance = 0.f; + + std::string mError; +}; + +float tonemapComparisonTolerance(PixelFormat format) noexcept; +TonemapComparisonStats compareTonemapArtifacts(const TonemapArtifact& reference, + const TonemapArtifact& candidate); + +} + +#endif diff --git a/indra/llrender/tests/lldrawpacketcontract_test.cpp b/indra/llrender/tests/lldrawpacketcontract_test.cpp new file mode 100644 index 00000000000..827d2a39533 --- /dev/null +++ b/indra/llrender/tests/lldrawpacketcontract_test.cpp @@ -0,0 +1,338 @@ +/** + * @file lldrawpacketcontract_test.cpp + * @brief Tests for the owned prepared draw packet. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "lldrawpacketcontract.h" +#include "lltut.h" + +#include + +namespace +{ +using namespace LLRenderContract; + +LegacyNormSpecDrawInputs completeInputs() +{ + LegacyNormSpecDrawInputs inputs; + inputs.mFrame = 91; + inputs.mPass = { 12 }; + inputs.mHandles = { BufferHandle{ 1, 7 }, BufferHandle{ 2, 9 }, PipelineHandle{ 6, 3 } }; + inputs.mDescriptors = { { ImageHandle{ 3, 4 }, SamplerHandle{ 5, 2 }, { 0, 3, 0, 1 } }, + { ImageHandle{ 4, 5 }, SamplerHandle{ 7, 3 }, { 0, 3, 0, 1 } }, + { ImageHandle{ 4, 5 }, SamplerHandle{ 8, 6 }, { 1, 2, 0, 1 } } }; + inputs.mPipelineKey = legacyNormSpecModernHDRPipelineKey(); + inputs.mFirstIndex = 18; + inputs.mIndexCount = 6; + inputs.mMinVertex = 11; + inputs.mMaxVertex = 14; + inputs.mModelMatrix[12] = 4.25f; + inputs.mDiffuseTextureMatrix[0] = 0.75f; + inputs.mDiffuseTextureMatrix[13] = 0.125f; + inputs.mSpecularRGBA = { 0.2f, 0.4f, 0.8f, 0.6f }; + inputs.mEnvironmentIntensity = 0.625f; + inputs.mAlphaCutoff = 0.375f; + inputs.mEmissiveBrightness = 1.f; + return inputs; +} + +} // namespace + +namespace tut +{ + +struct draw_packet_contract_test +{ +}; + +using draw_packet_contract_test_group = test_group; +using draw_packet_contract_test_object = draw_packet_contract_test_group::object; +draw_packet_contract_test_group draw_packet_contract_tests("draw packet contract"); + +template<> +template<> +void draw_packet_contract_test_object::test<1>() +{ + const auto packet = buildLegacyNormSpecDrawPacket(completeInputs()); + ensure("complete packet builds", packet.has_value()); + ensure("complete packet validates", validLegacyNormSpecDrawPacket(*packet)); + ensure("frame and pass identities survive preparation", packet->mFrame == 91 && packet->mPass == PassId{ 12 }); + ensure("logical pipeline key is named and deterministic", + packet->mPipelineKey == legacyNormSpecModernHDRPipelineKey() && + packet->mPipelineKey.mProgram.mName == "deferred.material.normspec" && + packet->mPipelineKey.mProgram.mVariant == LEGACY_NORMSPEC_PRODUCTION_VARIANT && + packet->mPipelineKey.mShaderVariant == LEGACY_NORMSPEC_PRODUCTION_SHADER_VARIANT && + packet->mPipelineKey.mTargetProfile == LegacyNormSpecTargetProfile::ModernHDR); + ensure("pipeline state is copied from explicit context", + packet->mPipelineKey.mVertexLayout == DrawVertexLayout::LegacyMaterialNormSpec && + packet->mPipelineKey.mTopology == PrimitiveTopology::TriangleList && packet->mPipelineKey.mDepthTestEnabled && + packet->mPipelineKey.mDepthWriteEnabled && packet->mPipelineKey.mColorTargets.size() == 4 && + packet->mPipelineKey.mColorTargets[2].mFormat == PixelFormat::RGBA16Unorm && + packet->mPipelineKey.mColorTargets[3].mFormat == PixelFormat::RGB16Float && + packet->mPipelineKey.mColorTargets[2].mWriteMask == 0xf && packet->mPipelineKey.mDepthFormat == PixelFormat::Depth24Unorm); + ensure("indexed draw range maps without changing units", + packet->mFirstIndex == 18 && packet->mIndexCount == 6 && packet->mMinVertex == 11 && packet->mMaxVertex == 14); + ensure("texture roles may resolve to the same image generation", + packet->mDescriptors.mNormal.mImage == packet->mDescriptors.mSpecular.mImage && + packet->mDescriptors.mNormal.mSampler != packet->mDescriptors.mSpecular.mSampler && + packet->mDescriptors.mSpecular.mRange.mBaseMipLevel == 1); + ensure("prepared constants retain their copied values", + packet->mModelMatrix[12] == 4.25f && packet->mDiffuseTextureMatrix[13] == 0.125f && packet->mSpecularRGBA[2] == 0.8f && + packet->mEnvironmentIntensity == 0.625f && packet->mAlphaCutoff == 0.375f && packet->mEmissiveBrightness == 1.f); +} + +template<> +template<> +void draw_packet_contract_test_object::test<2>() +{ + LegacyNormSpecDrawInputs inputs = completeInputs(); + const auto packet = buildLegacyNormSpecDrawPacket(inputs); + ensure("baseline packet builds", packet.has_value()); + const LegacyNormSpecDrawPacket owned = *packet; + + inputs.mDescriptors.mDiffuse.mImage.mGeneration++; + inputs.mModelMatrix.fill(-7.f); + inputs.mDiffuseTextureMatrix.fill(9.f); + inputs.mSpecularRGBA.fill(0.f); + inputs.mEnvironmentIntensity = 0.f; + inputs.mAlphaCutoff = 1.f; + inputs.mEmissiveBrightness = 0.f; + + ensure("source mutation cannot change the prepared packet", *packet == owned); + LegacyNormSpecDrawPacket changed = owned; + changed.mMaxVertex++; + ensure("packet equality observes draw data", changed != owned); + changed = owned; + changed.mPipelineKey.mProgram.mName = "viewer.deferred.legacy-material.other"; + ensure("validator rejects a different logical program", !validLegacyNormSpecDrawPacket(changed)); +} + +template<> +template<> +void draw_packet_contract_test_object::test<3>() +{ + LegacyNormSpecDrawInputs invalid = completeInputs(); + invalid.mFrame = 0; + ensure("zero frame identities are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mPass = {}; + ensure("zero pass identities are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mHandles.mVertexBuffer.mGeneration = 0; + ensure("zero handle generations are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mHandles.mIndexBuffer = invalid.mHandles.mVertexBuffer; + ensure("vertex and index streams require distinct handles", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mDescriptors.mNormal.mSampler.mGeneration = 0; + ensure("every descriptor owns a valid sampler handle", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mDescriptors.mSpecular.mRange.mBaseMipLevel = std::numeric_limits::max(); + invalid.mDescriptors.mSpecular.mRange.mMipLevelCount = 1; + ensure("overflowing descriptor ranges are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mIndexCount = 0; + ensure("empty index ranges are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mFirstIndex = std::numeric_limits::max() - 1; + invalid.mIndexCount = 3; + ensure("overflowing index ranges are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mMinVertex = 15; + invalid.mMaxVertex = 14; + ensure("inverted vertex ranges are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mIndexType = static_cast(255); + ensure("unknown index types are rejected", !buildLegacyNormSpecDrawPacket(invalid)); +} + +template<> +template<> +void draw_packet_contract_test_object::test<4>() +{ + LegacyNormSpecDrawInputs invalid = completeInputs(); + invalid.mModelMatrix[0] = std::numeric_limits::infinity(); + ensure("non-finite matrices are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mSpecularRGBA[2] = 1.01f; + ensure("specular components outside unit range are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mEnvironmentIntensity = -0.01f; + ensure("environment intensity outside unit range is rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mAlphaCutoff = 1.01f; + ensure("alpha cutoff outside unit range is rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mEmissiveBrightness = 0.5f; + ensure("emissive brightness must encode fullbright as zero or one", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mPipelineKey.mVertexLayout = static_cast(255); + ensure("unknown vertex layouts are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mPipelineKey.mSamples = 3; + ensure("non-canonical sample counts are rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mPipelineKey.mCullMode = CullMode::Disabled; + ensure("non-canonical culling is rejected", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mPipelineKey.mDepthTestEnabled = false; + invalid.mPipelineKey.mDepthWriteEnabled = true; + ensure("depth writes require depth testing", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mPipelineKey.mColorTargets[0].mBlendEnabled = true; + ensure("opaque color targets reject blending", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mPipelineKey.mColorTargets[0].mWriteMask = 0x7; + ensure("normspec color targets require full writes", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mPipelineKey.mColorTargets[1].mFormat = PixelFormat::RGBA16Unorm; + ensure("normspec color target order is canonical", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mPipelineKey.mColorTargets[0].mFormat = PixelFormat::Depth24Unorm; + ensure("depth formats cannot be color targets", !buildLegacyNormSpecDrawPacket(invalid)); + + invalid = completeInputs(); + invalid.mPipelineKey.mDepthFormat = PixelFormat::RGBA8Unorm; + ensure("color formats cannot be depth targets", !buildLegacyNormSpecDrawPacket(invalid)); +} + +template<> +template<> +void draw_packet_contract_test_object::test<5>() +{ + const LegacyNormSpecPipelineKey diagnostic = legacyNormSpecDiagnosticPipelineKey(); + const LegacyNormSpecPipelineKey modern_hdr = legacyNormSpecModernHDRPipelineKey(); + const LegacyNormSpecPipelineKey compatibility = legacyNormSpecCompatibilityPipelineKey(); + + ensure("the diagnostic profile is an explicit three-target variant", + validLegacyNormSpecPipelineKey(diagnostic) && diagnostic.mTargetProfile == LegacyNormSpecTargetProfile::DiagnosticThreeTarget && + diagnostic.mShaderVariant == LEGACY_NORMSPEC_DIAGNOSTIC_SHADER_VARIANT && + diagnostic.mProgram.mVariant == LEGACY_NORMSPEC_DIAGNOSTIC_VARIANT && diagnostic.mColorTargets.size() == 3 && + diagnostic.mColorTargets[0].mFormat == PixelFormat::RGBA8Unorm && + diagnostic.mColorTargets[1].mFormat == PixelFormat::RGBA8Unorm && + diagnostic.mColorTargets[2].mFormat == PixelFormat::RGBA16Unorm); + ensure("the modern HDR production profile is exact", + validLegacyNormSpecPipelineKey(modern_hdr) && modern_hdr.mTargetProfile == LegacyNormSpecTargetProfile::ModernHDR && + modern_hdr.mShaderVariant == LEGACY_NORMSPEC_PRODUCTION_SHADER_VARIANT && + modern_hdr.mProgram.mVariant == LEGACY_NORMSPEC_PRODUCTION_VARIANT && modern_hdr.mColorTargets.size() == 4 && + modern_hdr.mColorTargets[0].mFormat == PixelFormat::RGBA8Unorm && + modern_hdr.mColorTargets[1].mFormat == PixelFormat::RGBA8Unorm && + modern_hdr.mColorTargets[2].mFormat == PixelFormat::RGBA16Unorm && + modern_hdr.mColorTargets[3].mFormat == PixelFormat::RGB16Float); + ensure("the compatibility production profile is exact", + validLegacyNormSpecPipelineKey(compatibility) && compatibility.mTargetProfile == LegacyNormSpecTargetProfile::Compatibility && + compatibility.mShaderVariant == LEGACY_NORMSPEC_PRODUCTION_SHADER_VARIANT && + compatibility.mProgram.mVariant == LEGACY_NORMSPEC_PRODUCTION_VARIANT && compatibility.mColorTargets.size() == 4 && + compatibility.mColorTargets[0].mFormat == PixelFormat::RGBA8Unorm && + compatibility.mColorTargets[1].mFormat == PixelFormat::RGBA8Unorm && + compatibility.mColorTargets[2].mFormat == PixelFormat::RGB10A2Unorm && + compatibility.mColorTargets[3].mFormat == PixelFormat::RGB8Unorm); + ensure("all normspec profiles retain the canonical depth target", + diagnostic.mDepthFormat == PixelFormat::Depth24Unorm && modern_hdr.mDepthFormat == PixelFormat::Depth24Unorm && + compatibility.mDepthFormat == PixelFormat::Depth24Unorm); +} + +template<> +template<> +void draw_packet_contract_test_object::test<6>() +{ + constexpr LegacyNormSpecEmissive emissive_values[]{ LegacyNormSpecEmissive::Disabled, LegacyNormSpecEmissive::Enabled }; + constexpr ShadowAssembly shadow_values[]{ ShadowAssembly::Disabled, ShadowAssembly::Sun, ShadowAssembly::SunAndSpot }; + + for (LegacyNormSpecEmissive emissive : emissive_values) + { + for (ShadowAssembly shadow : shadow_values) + { + const LegacyNormSpecShaderVariant variant{ emissive, shadow }; + const auto encoded = encodeLegacyNormSpecShaderVariant(variant); + ensure("every known typed combination encodes", encoded.has_value()); + ensure("every known encoding round-trips", decodeLegacyNormSpecShaderVariant(*encoded) == variant); + } + } + + ensure("the diagnostic variant has the stable zero encoding", + encodeLegacyNormSpecShaderVariant(LEGACY_NORMSPEC_DIAGNOSTIC_SHADER_VARIANT) == LEGACY_NORMSPEC_DIAGNOSTIC_VARIANT); + ensure("the production default has the stable emissive and Sun-plus-spot encoding", + encodeLegacyNormSpecShaderVariant(LEGACY_NORMSPEC_PRODUCTION_SHADER_VARIANT) == LEGACY_NORMSPEC_PRODUCTION_VARIANT); + ensure("unknown emissive values cannot encode", + !encodeLegacyNormSpecShaderVariant({ static_cast(2), ShadowAssembly::Disabled })); + ensure("unknown shadow assemblies cannot encode", + !encodeLegacyNormSpecShaderVariant({ LegacyNormSpecEmissive::Enabled, static_cast(3) })); + ensure("the reserved shadow combination cannot decode", !decodeLegacyNormSpecShaderVariant(6) && !decodeLegacyNormSpecShaderVariant(7)); + ensure("unknown high bits cannot decode", + !decodeLegacyNormSpecShaderVariant(8) && !decodeLegacyNormSpecShaderVariant(std::numeric_limits::max())); +} + +template<> +template<> +void draw_packet_contract_test_object::test<7>() +{ + LegacyNormSpecPipelineKey mixed = legacyNormSpecDiagnosticPipelineKey(); + mixed.mTargetProfile = LegacyNormSpecTargetProfile::ModernHDR; + ensure("diagnostic variants cannot claim a production target profile", !validLegacyNormSpecPipelineKey(mixed)); + + mixed = legacyNormSpecModernHDRPipelineKey(); + mixed.mProgram.mVariant = LEGACY_NORMSPEC_DIAGNOSTIC_VARIANT; + mixed.mShaderVariant = LEGACY_NORMSPEC_DIAGNOSTIC_SHADER_VARIANT; + ensure("diagnostic variants cannot use production targets", !validLegacyNormSpecPipelineKey(mixed)); + + mixed = legacyNormSpecModernHDRPipelineKey(); + mixed.mShaderVariant = { LegacyNormSpecEmissive::Enabled, ShadowAssembly::Sun }; + mixed.mProgram.mVariant = *encodeLegacyNormSpecShaderVariant(mixed.mShaderVariant); + ensure("non-default known shader combinations are not production descriptions", !validLegacyNormSpecPipelineKey(mixed)); + + mixed = legacyNormSpecModernHDRPipelineKey(); + mixed.mProgram.mVariant = 8; + ensure("unknown program bits are rejected", !validLegacyNormSpecPipelineKey(mixed)); + + mixed = legacyNormSpecModernHDRPipelineKey(); + mixed.mTargetProfile = LegacyNormSpecTargetProfile::Compatibility; + ensure("target profile labels cannot be mixed with another exact target list", !validLegacyNormSpecPipelineKey(mixed)); + + mixed = legacyNormSpecModernHDRPipelineKey(); + std::swap(mixed.mColorTargets[2], mixed.mColorTargets[3]); + ensure("production target order is part of the profile", !validLegacyNormSpecPipelineKey(mixed)); + + mixed = legacyNormSpecCompatibilityPipelineKey(); + mixed.mColorTargets.pop_back(); + ensure("partial production target lists are rejected", !validLegacyNormSpecPipelineKey(mixed)); + + mixed = legacyNormSpecCompatibilityPipelineKey(); + mixed.mTargetProfile = static_cast(255); + ensure("unknown target profiles are rejected", !validLegacyNormSpecPipelineKey(mixed)); +} + +} // namespace tut diff --git a/indra/llrender/tests/llmaterialdiagnostic_test.cpp b/indra/llrender/tests/llmaterialdiagnostic_test.cpp new file mode 100644 index 00000000000..03162e7749a --- /dev/null +++ b/indra/llrender/tests/llmaterialdiagnostic_test.cpp @@ -0,0 +1,262 @@ +/** + * @file llmaterialdiagnostic_test.cpp + * @brief Tests for the indexed material fixture and artifact. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llmaterialdiagnostic.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; + +MaterialArtifact completeArtifact() +{ + MaterialArtifact artifact = makeMaterialArtifact(); + artifact.mGBuffer0RGBA8.reserve(MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT); + artifact.mGBuffer1RGBA8.reserve(MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT); + artifact.mGBuffer2RGBA16.reserve(MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT); + for (std::size_t component = 0; component < MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT; ++component) + { + artifact.mGBuffer0RGBA8.push_back(materialUnorm8(static_cast((component * 17 + 3) % 256))); + artifact.mGBuffer1RGBA8.push_back(materialUnorm8(static_cast((component * 29 + 101) % 256))); + artifact.mGBuffer2RGBA16.push_back(materialUnorm16(static_cast((component * 1237 + 4001) % 65536))); + } + artifact.mDepth24.reserve(MATERIAL_DIAGNOSTIC_DEPTH_COMPONENT_COUNT); + for (std::size_t pixel = 0; pixel < MATERIAL_DIAGNOSTIC_DEPTH_COMPONENT_COUNT; ++pixel) + { + artifact.mDepth24.push_back(materialDepth24(static_cast((pixel * 0x1f123U + 0x345678U) % 0x1000000U))); + } + return artifact; +} + +std::filesystem::path temporaryArtifactPath() +{ + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + return std::filesystem::temp_directory_path() / ("llmaterialdiagnostic-" + std::to_string(suffix) + ".bin"); +} + +float vertexFloat(const MaterialFixture& fixture, std::size_t byte_offset) +{ + float value = 0.f; + std::memcpy(&value, fixture.mVertexBytes.data() + byte_offset, sizeof(value)); + return value; +} + +} // namespace + +namespace tut +{ + +struct material_diagnostic_test +{ +}; + +using material_diagnostic_test_group = test_group; +using material_diagnostic_test_object = material_diagnostic_test_group::object; +material_diagnostic_test_group material_diagnostic_tests("material diagnostic"); + +template<> +template<> +void material_diagnostic_test_object::test<1>() +{ + const MaterialFixture fixture = makeMaterialFixture(); + ensure("fixture is 8 by 8", fixture.mExtent.mWidth == 8 && fixture.mExtent.mHeight == 8); + ensure("fixture rows start at the bottom", fixture.mRowOrigin == RowOrigin::BottomLeft); + ensure("fixture owns the packed viewer vertex bytes", fixture.mVertexBytes.size() == 304); + ensure("fixture starts with the asymmetric first position", + vertexFloat(fixture, MATERIAL_POSITION_OFFSET) == -0.82f && vertexFloat(fixture, MATERIAL_POSITION_OFFSET + 4) == -0.74f); + ensure("fixture owns two counter-clockwise indexed triangles", fixture.mIndices == std::array{ 0, 1, 2, 0, 2, 3 }); + ensure("all three texture chains have the exact mip payload size", + fixture.mTextureRGBA8[0].size() == 84 && fixture.mTextureRGBA8[1].size() == 84 && fixture.mTextureRGBA8[2].size() == 84); + ensure("texture inputs and mip levels are observably distinct", + fixture.mTextureRGBA8[0][0] != fixture.mTextureRGBA8[1][0] && + fixture.mTextureRGBA8[0][MATERIAL_TEXTURE_MIP_BYTE_OFFSETS[1]] != fixture.mTextureRGBA8[0][0] && + fixture.mTextureRGBA8[2][MATERIAL_TEXTURE_MIP_BYTE_OFFSETS[2]] != + fixture.mTextureRGBA8[1][MATERIAL_TEXTURE_MIP_BYTE_OFFSETS[2]]); + ensure("fixture carries the complete material parameters", + sizeof(fixture.mParameters) == 272 && fixture.mParameters.mEnvironmentIntensity == 0.625f && + fixture.mParameters.mEmissiveBrightness == 1.f && fixture.mParameters.mMirror == 1.f && + fixture.mParameters.mClipPlane[0] == 1.f && fixture.mParameters.mClipPlane[3] == 0.f); + ensure("depth values are valid 24-bit storage codes", + std::all_of(fixture.mDepth24.begin(), fixture.mDepth24.end(), [](std::uint32_t value) { return value <= 0xffffffU; })); + ensure("three output sentinels differ", + fixture.mGBuffer0SentinelRGBA8[0] != fixture.mGBuffer1SentinelRGBA8[0] && + fixture.mGBuffer2SentinelRGBA16[0] != fixture.mGBuffer0SentinelRGBA8[0]); + ensure_equals("fixture drift requires an explicit fixture revision", materialFixtureFingerprint(), + std::uint64_t{ 0x4e52ab4e75b6748bULL }); + + const MaterialCase diagnostic_case = makeMaterialCase(); + auto decoded = decodeMaterialFrame(diagnostic_case.mFrame); + ensure("fixture has one canonical owned frame", decoded.has_value()); + ensure("case inputs agree with decoded parameters", + decoded->mFrame == 1 && decoded->mParameters == fixture.mParameters && + diagnostic_case.mInputs.mParameters == fixture.mParameters); +} + +template<> +template<> +void material_diagnostic_test_object::test<2>() +{ + const MaterialArtifact artifact = completeArtifact(); + std::string error; + std::vector first_encoding; + std::vector second_encoding; + ensure("complete artifact validates", validateMaterialArtifact(artifact, &error)); + ensure("complete artifact encodes", encodeMaterialArtifact(artifact, first_encoding, &error)); + ensure("material artifact has its fixed byte count", first_encoding.size() == MATERIAL_ARTIFACT_BYTE_SIZE); + ensure("material artifact encoding is deterministic", + encodeMaterialArtifact(artifact, second_encoding, &error) && first_encoding == second_encoding); + + MaterialArtifact decoded; + ensure("canonical material artifact decodes", decodeMaterialArtifact(first_encoding, decoded, &error)); + ensure("material artifact round trip preserves all planes", decoded == artifact); + + const std::filesystem::path path = temporaryArtifactPath(); + std::filesystem::path predictable_temporary = path; + predictable_temporary += ".tmp"; + std::error_code cleanup_error; + std::filesystem::remove(path, cleanup_error); + std::filesystem::remove_all(predictable_temporary, cleanup_error); + ensure("predictable temporary sibling can already exist", std::filesystem::create_directory(predictable_temporary)); + ensure("writer publishes through a unique sibling", writeMaterialArtifact(path, artifact, &error)); + ensure("writer leaves the predictable sibling untouched", + std::filesystem::exists(path) && std::filesystem::is_directory(predictable_temporary)); + bool unique_temporary_remains = false; + const std::string temporary_prefix = path.filename().string() + ".tmp."; + for (const std::filesystem::directory_entry& entry : std::filesystem::directory_iterator(path.parent_path())) + { + unique_temporary_remains = unique_temporary_remains || entry.path().filename().string().starts_with(temporary_prefix); + } + ensure("published artifact has no unique temporary sibling", !unique_temporary_remains); + + MaterialArtifact from_file; + ensure("published artifact reads back", readMaterialArtifact(path, from_file, &error)); + ensure("file round trip preserves every plane", from_file == artifact); + ensure("atomic writer refuses to replace an existing artifact", !writeMaterialArtifact(path, artifact, &error)); + std::filesystem::remove(path, cleanup_error); + std::filesystem::remove_all(predictable_temporary, cleanup_error); +} + +template<> +template<> +void material_diagnostic_test_object::test<3>() +{ + const MaterialArtifact artifact = completeArtifact(); + std::string error; + std::vector encoded; + ensure("baseline material artifact encodes", encodeMaterialArtifact(artifact, encoded, &error)); + const std::vector baseline = encoded; + + MaterialArtifact invalid = artifact; + invalid.mDepth24.pop_back(); + ensure("missing depth values are rejected", !validateMaterialArtifact(invalid, &error)); + + invalid = artifact; + invalid.mGBuffer0RGBA8[0] = std::numeric_limits::infinity(); + ensure("non-finite color values are rejected", !validateMaterialArtifact(invalid, &error)); + + invalid = artifact; + invalid.mGBuffer0RGBA8[0] = 0.5f; + ensure("non-storage RGBA8 values are rejected", !validateMaterialArtifact(invalid, &error)); + + invalid = artifact; + invalid.mGBuffer2RGBA16[0] = 0.5f; + ensure("non-storage RGBA16 values are rejected", !validateMaterialArtifact(invalid, &error)); + + invalid = artifact; + invalid.mDepth24[0] = 0.5f; + ensure("non-storage depth24 values are rejected", !validateMaterialArtifact(invalid, &error)); + + MaterialArtifact decoded; + std::vector corrupt = baseline; + corrupt[0] ^= 0xffU; + ensure("bad material artifact magic is rejected", !decodeMaterialArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[11] = 2; + ensure("unknown material artifact schema is rejected", !decodeMaterialArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[47] = 2; + ensure("non-canonical first plane id is rejected", !decodeMaterialArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[60] = 0x7fU; + corrupt[61] = 0x80U; + corrupt[62] = 0; + corrupt[63] = 0; + ensure("non-finite wire values are rejected", !decodeMaterialArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[60] = 0x80U; + corrupt[61] = 0; + corrupt[62] = 0; + corrupt[63] = 0; + ensure("negative zero is not a canonical wire value", !decodeMaterialArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt.push_back(0); + ensure("trailing material artifact bytes are rejected", !decodeMaterialArtifact(corrupt, decoded, &error)); +} + +template<> +template<> +void material_diagnostic_test_object::test<4>() +{ + MaterialArtifact reference; + reference.mGBuffer0RGBA8.assign(MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT, 0.f); + reference.mGBuffer1RGBA8.assign(MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT, 0.f); + reference.mGBuffer2RGBA16.assign(MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT, 0.f); + reference.mDepth24.assign(MATERIAL_DIAGNOSTIC_DEPTH_COMPONENT_COUNT, 0.f); + + MaterialArtifact candidate = reference; + candidate.mGBuffer0RGBA8[0] = materialUnorm8(1); + candidate.mGBuffer2RGBA16[0] = materialUnorm16(1); + candidate.mDepth24[0] = materialDepth24(1); + + MaterialComparisonStats stats = compareMaterialArtifacts(reference, candidate); + ensure("one storage code per format mismatches", stats.mComparable && !stats.mMatch && stats.mMismatchCount == 3); + ensure("comparison reports every color and depth component", + stats.mComparedComponents == MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT * 3 + MATERIAL_DIAGNOSTIC_DEPTH_COMPONENT_COUNT); + ensure("first one-code mismatch identifies its plane, pixel, and channel", + stats.mFirstMismatchPlane == 1 && stats.mFirstMismatchPixel == 0 && stats.mFirstMismatchChannel == 0 && + stats.mFirstReference == 0.f && stats.mFirstCandidate == materialUnorm8(1) && + stats.mFirstTolerance == MATERIAL_RGBA8_TOLERANCE); + + candidate.mGBuffer0RGBA8[1] = materialUnorm8(2); + candidate.mGBuffer2RGBA16[1] = materialUnorm16(2); + candidate.mDepth24[1] = materialDepth24(2); + stats = compareMaterialArtifacts(reference, candidate); + ensure("all distinct storage codes mismatch", stats.mComparable && !stats.mMatch && stats.mMismatchCount == 6); + ensure("first mismatch remains the first one-code difference", + stats.mFirstMismatchPlane == 1 && stats.mFirstMismatchPixel == 0 && stats.mFirstMismatchChannel == 0 && + stats.mFirstReference == 0.f && stats.mFirstCandidate == materialUnorm8(1) && + stats.mFirstTolerance == MATERIAL_RGBA8_TOLERANCE); + + candidate.mGBuffer0RGBA8[0] = std::numeric_limits::quiet_NaN(); + stats = compareMaterialArtifacts(reference, candidate); + ensure("non-finite candidates fail comparison preflight", !stats.mComparable && !stats.mMatch && !stats.mError.empty()); +} + +} // namespace tut diff --git a/indra/llrender/tests/llmaterialparametercontract_test.cpp b/indra/llrender/tests/llmaterialparametercontract_test.cpp new file mode 100644 index 00000000000..e3a1c75ac65 --- /dev/null +++ b/indra/llrender/tests/llmaterialparametercontract_test.cpp @@ -0,0 +1,356 @@ +/** + * @file llmaterialparametercontract_test.cpp + * @brief Tests for materializing owned production material parameters. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llmaterialparametercontract.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; + +static_assert(sizeof(MaterialParameters) == 68 * sizeof(float)); +static_assert(offsetof(MaterialParameters, mModelviewMatrix) == 0 * sizeof(float)); +static_assert(offsetof(MaterialParameters, mModelviewProjectionMatrix) == 16 * sizeof(float)); +static_assert(offsetof(MaterialParameters, mNormalMatrix) == 32 * sizeof(float)); +static_assert(offsetof(MaterialParameters, mTextureMatrix0) == 41 * sizeof(float)); +static_assert(offsetof(MaterialParameters, mSpecularColor) == 57 * sizeof(float)); +static_assert(offsetof(MaterialParameters, mClipPlane) == 61 * sizeof(float)); +static_assert(offsetof(MaterialParameters, mEnvironmentIntensity) == 65 * sizeof(float)); +static_assert(offsetof(MaterialParameters, mEmissiveBrightness) == 66 * sizeof(float)); +static_assert(offsetof(MaterialParameters, mMirror) == 67 * sizeof(float)); + +LegacyNormSpecDrawInputs completeInputs(const LegacyNormSpecPipelineKey& pipeline_key) +{ + LegacyNormSpecDrawInputs inputs; + inputs.mFrame = 91; + inputs.mPass = { 12 }; + inputs.mHandles = { BufferHandle{ 1, 7 }, BufferHandle{ 2, 9 }, PipelineHandle{ 6, 3 } }; + inputs.mDescriptors = { { ImageHandle{ 3, 4 }, SamplerHandle{ 5, 2 }, { 0, 3, 0, 1 } }, + { ImageHandle{ 4, 5 }, SamplerHandle{ 7, 3 }, { 0, 3, 0, 1 } }, + { ImageHandle{ 8, 6 }, SamplerHandle{ 9, 4 }, { 1, 2, 0, 1 } } }; + inputs.mPipelineKey = pipeline_key; + inputs.mFirstIndex = 18; + inputs.mIndexCount = 6; + inputs.mMinVertex = 11; + inputs.mMaxVertex = 14; + inputs.mDiffuseTextureMatrix[0] = 0.75f; + inputs.mDiffuseTextureMatrix[5] = 0.5f; + inputs.mDiffuseTextureMatrix[12] = 0.125f; + inputs.mDiffuseTextureMatrix[13] = -0.25f; + inputs.mSpecularRGBA = { 0.2f, 0.4f, 0.8f, 0.6f }; + inputs.mEnvironmentIntensity = 0.625f; + inputs.mAlphaCutoff = 0.375f; + inputs.mEmissiveBrightness = 1.f; + return inputs; +} + +std::optional completePacket(const LegacyNormSpecPipelineKey& pipeline_key) +{ + return buildLegacyNormSpecDrawPacket(completeInputs(pipeline_key)); +} + +LegacyNormSpecWorldParameterContext completeContext() +{ + LegacyNormSpecWorldParameterContext context; + context.mFrame = 91; + context.mPass = { 12 }; + context.mBaseModelview = DRAW_IDENTITY_MATRIX4; + context.mProjection = DRAW_IDENTITY_MATRIX4; + context.mEyeClipPlane = { 0.25f, -0.5f, 0.75f, -1.25f }; + return context; +} + +DrawMatrix4 affine(float scale_x, float scale_y, float scale_z, float translate_x, float translate_y, float translate_z) +{ + DrawMatrix4 matrix = DRAW_IDENTITY_MATRIX4; + matrix[0] = scale_x; + matrix[5] = scale_y; + matrix[10] = scale_z; + matrix[12] = translate_x; + matrix[13] = translate_y; + matrix[14] = translate_z; + return matrix; +} + +template +bool approximatelyEqual(const std::array& left, const std::array& right, float tolerance = 1.e-6f) +{ + for (std::size_t index = 0; index < Size; ++index) + { + if (!std::isfinite(left[index]) || !std::isfinite(right[index]) || std::fabs(left[index] - right[index]) > tolerance) + { + return false; + } + } + return true; +} + +std::array parameterWords(const MaterialParameters& parameters) +{ + std::array words{}; + std::memcpy(words.data(), ¶meters, sizeof(parameters)); + return words; +} + +} // namespace + +namespace tut +{ + +struct material_parameter_contract_test +{ +}; + +using material_parameter_contract_test_group = test_group; +using material_parameter_contract_test_object = material_parameter_contract_test_group::object; +material_parameter_contract_test_group material_parameter_contract_tests("material parameter contract"); + +template<> +template<> +void material_parameter_contract_test_object::test<1>() +{ + const auto packet = completePacket(legacyNormSpecModernHDRPipelineKey()); + ensure("production packet builds", packet.has_value()); + const LegacyNormSpecWorldParameterContext context = completeContext(); + const auto parameters = materializeLegacyNormSpecWorldParameters(*packet, context); + + ensure("identity transforms materialize", parameters.has_value()); + ensure("materialized parameters validate", validMaterialParameters(*parameters)); + ensure("identity modelview and projection are preserved", + parameters->mModelviewMatrix == DRAW_IDENTITY_MATRIX4 && parameters->mModelviewProjectionMatrix == DRAW_IDENTITY_MATRIX4); + ensure("identity normal matrix is preserved", + parameters->mNormalMatrix == std::array{ 1.f, 0.f, 0.f, 0.f, 1.f, 0.f, 0.f, 0.f, 1.f }); + ensure("draw-owned parameters are copied", + parameters->mTextureMatrix0 == packet->mDiffuseTextureMatrix && parameters->mSpecularColor == packet->mSpecularRGBA && + parameters->mEnvironmentIntensity == packet->mEnvironmentIntensity && + parameters->mEmissiveBrightness == packet->mEmissiveBrightness); + ensure("World clip state is copied with mirror disabled", + parameters->mClipPlane == context.mEyeClipPlane && parameters->mMirror == 0.f); + + const std::array words = parameterWords(*parameters); + ensure("derived matrices begin at their declared word offsets", + words[0] == 1.f && words[16] == 1.f && words[32] == 1.f && words[36] == 1.f && words[40] == 1.f); + ensure("copied fields begin at their declared word offsets", + words[41] == packet->mDiffuseTextureMatrix[0] && words[57] == packet->mSpecularRGBA[0] && + words[61] == context.mEyeClipPlane[0] && words[65] == packet->mEnvironmentIntensity && + words[66] == packet->mEmissiveBrightness && words[67] == 0.f); +} + +template<> +template<> +void material_parameter_contract_test_object::test<2>() +{ + auto packet = completePacket(legacyNormSpecModernHDRPipelineKey()); + ensure("multiplication packet builds", packet.has_value()); + packet->mModelMatrix = affine(2.f, 3.f, 4.f, 1.f, 2.f, 3.f); + + LegacyNormSpecWorldParameterContext context = completeContext(); + context.mBaseModelview = affine(1.f, 1.f, 1.f, 10.f, 20.f, 30.f); + context.mProjection = affine(5.f, 6.f, 7.f, 0.f, 0.f, 0.f); + + const auto parameters = materializeLegacyNormSpecWorldParameters(*packet, context); + ensure("noncommuting transforms materialize", parameters.has_value()); + ensure("base modelview post-multiplies the draw model", + parameters->mModelviewMatrix[0] == 2.f && parameters->mModelviewMatrix[5] == 3.f && parameters->mModelviewMatrix[10] == 4.f && + parameters->mModelviewMatrix[12] == 11.f && parameters->mModelviewMatrix[13] == 22.f && + parameters->mModelviewMatrix[14] == 33.f); + ensure("projection pre-multiplies the composed modelview", + parameters->mModelviewProjectionMatrix[0] == 10.f && parameters->mModelviewProjectionMatrix[5] == 18.f && + parameters->mModelviewProjectionMatrix[10] == 28.f && parameters->mModelviewProjectionMatrix[12] == 55.f && + parameters->mModelviewProjectionMatrix[13] == 132.f && parameters->mModelviewProjectionMatrix[14] == 231.f); + ensure("ordered transform output validates", validMaterialParameters(*parameters)); +} + +template<> +template<> +void material_parameter_contract_test_object::test<3>() +{ + auto packet = completePacket(legacyNormSpecModernHDRPipelineKey()); + ensure("normal-matrix packet builds", packet.has_value()); + + DrawMatrix4 rotated_nonuniform_scale = DRAW_IDENTITY_MATRIX4; + rotated_nonuniform_scale[0] = 0.f; + rotated_nonuniform_scale[1] = 2.f; + rotated_nonuniform_scale[4] = -3.f; + rotated_nonuniform_scale[5] = 0.f; + rotated_nonuniform_scale[10] = 4.f; + packet->mModelMatrix = rotated_nonuniform_scale; + + const auto parameters = materializeLegacyNormSpecWorldParameters(*packet, completeContext()); + ensure("rotated nonuniform scale materializes", parameters.has_value()); + const std::array expected_normal{ 0.f, 0.5f, 0.f, -1.f / 3.f, 0.f, 0.f, 0.f, 0.f, 0.25f }; + ensure("normal matrix is the inverse transpose", approximatelyEqual(parameters->mNormalMatrix, expected_normal)); + ensure("normal-matrix output validates", validMaterialParameters(*parameters)); +} + +template<> +template<> +void material_parameter_contract_test_object::test<4>() +{ + auto modern = completePacket(legacyNormSpecModernHDRPipelineKey()); + auto compatibility = completePacket(legacyNormSpecCompatibilityPipelineKey()); + ensure("both production profile packets build", modern.has_value() && compatibility.has_value()); + + modern->mAlphaCutoff = 0.125f; + LegacyNormSpecDrawPacket same_profile_alpha = *modern; + same_profile_alpha.mAlphaCutoff = 0.875f; + compatibility->mAlphaCutoff = modern->mAlphaCutoff; + LegacyNormSpecWorldParameterContext context = completeContext(); + const auto modern_parameters = materializeLegacyNormSpecWorldParameters(*modern, context); + const auto same_profile_alpha_parameters = materializeLegacyNormSpecWorldParameters(same_profile_alpha, context); + const auto compatibility_parameters = materializeLegacyNormSpecWorldParameters(*compatibility, context); + ensure("both production profiles and alpha fixtures materialize", + modern_parameters.has_value() && same_profile_alpha_parameters.has_value() && compatibility_parameters.has_value()); + ensure("unused alpha cutoff does not change the parameter block", *modern_parameters == *same_profile_alpha_parameters); + ensure("production target profile does not change the parameter block", *modern_parameters == *compatibility_parameters); + + const MaterialParameters owned = *modern_parameters; + modern->mModelMatrix.fill(7.f); + modern->mDiffuseTextureMatrix.fill(-3.f); + modern->mSpecularRGBA.fill(0.f); + context.mBaseModelview.fill(9.f); + context.mProjection.fill(-5.f); + context.mEyeClipPlane.fill(4.f); + ensure("source mutation cannot change returned parameters", *modern_parameters == owned); +} + +template<> +template<> +void material_parameter_contract_test_object::test<5>() +{ + const auto diagnostic = completePacket(legacyNormSpecDiagnosticPipelineKey()); + const auto production = completePacket(legacyNormSpecModernHDRPipelineKey()); + ensure("profile rejection fixtures build", diagnostic.has_value() && production.has_value()); + ensure("the diagnostic profile is not a production World material packet", + !materializeLegacyNormSpecWorldParameters(*diagnostic, completeContext())); + + LegacyNormSpecDrawPacket malformed = *production; + malformed.mIndexCount = 0; + ensure("malformed draw ranges are rejected", + !validLegacyNormSpecDrawPacket(malformed) && !materializeLegacyNormSpecWorldParameters(malformed, completeContext())); + + malformed = *production; + malformed.mHandles.mPipeline.mGeneration = 0; + ensure("malformed resource generations are rejected", + !validLegacyNormSpecDrawPacket(malformed) && !materializeLegacyNormSpecWorldParameters(malformed, completeContext())); +} + +template<> +template<> +void material_parameter_contract_test_object::test<6>() +{ + const auto packet = completePacket(legacyNormSpecModernHDRPipelineKey()); + ensure("identity rejection packet builds", packet.has_value()); + + LegacyNormSpecWorldParameterContext context = completeContext(); + context.mFrame = 0; + ensure("zero context frame is rejected", !materializeLegacyNormSpecWorldParameters(*packet, context)); + context = completeContext(); + context.mPass = {}; + ensure("zero context pass is rejected", !materializeLegacyNormSpecWorldParameters(*packet, context)); + context = completeContext(); + context.mFrame += 1; + ensure("mismatched frame is rejected", !materializeLegacyNormSpecWorldParameters(*packet, context)); + context = completeContext(); + context.mPass.mValue += 1; + ensure("mismatched pass is rejected", !materializeLegacyNormSpecWorldParameters(*packet, context)); + + LegacyNormSpecDrawPacket malformed = *packet; + malformed.mFrame = 0; + ensure("zero packet frame is rejected", !materializeLegacyNormSpecWorldParameters(malformed, completeContext())); + malformed = *packet; + malformed.mPass = {}; + ensure("zero packet pass is rejected", !materializeLegacyNormSpecWorldParameters(malformed, completeContext())); +} + +template<> +template<> +void material_parameter_contract_test_object::test<7>() +{ + const auto packet = completePacket(legacyNormSpecModernHDRPipelineKey()); + ensure("numeric rejection packet builds", packet.has_value()); + + LegacyNormSpecWorldParameterContext context = completeContext(); + context.mBaseModelview[0] = std::numeric_limits::quiet_NaN(); + ensure("non-finite base modelview is rejected", !materializeLegacyNormSpecWorldParameters(*packet, context)); + context = completeContext(); + context.mProjection[5] = std::numeric_limits::infinity(); + ensure("non-finite projection is rejected", !materializeLegacyNormSpecWorldParameters(*packet, context)); + context = completeContext(); + context.mEyeClipPlane[2] = -std::numeric_limits::infinity(); + ensure("non-finite eye-space clip plane is rejected", !materializeLegacyNormSpecWorldParameters(*packet, context)); + + LegacyNormSpecDrawPacket singular = *packet; + singular.mModelMatrix[0] = 0.f; + ensure("finite singular modelview is rejected", + validLegacyNormSpecDrawPacket(singular) && !materializeLegacyNormSpecWorldParameters(singular, completeContext())); + + // Row 2 is exactly row 0 plus four times row 1. Floating-point elimination + // must not mistake its cancellation residue for an invertible pivot. + singular.mModelMatrix = { 3.f, 14.f, 59.f, 0.f, -6.f, 12.f, 42.f, 0.f, -8.f, -18.f, -80.f, 0.f, -7.f, -20.f, -87.f, 1.f }; + ensure("dependent nonzero rows are rejected", + validLegacyNormSpecDrawPacket(singular) && !materializeLegacyNormSpecWorldParameters(singular, completeContext())); + + LegacyNormSpecDrawPacket small_but_well_scaled = *packet; + small_but_well_scaled.mModelMatrix[0] = 1.e-20f; + ensure("a small well-scaled invertible axis is accepted", + materializeLegacyNormSpecWorldParameters(small_but_well_scaled, completeContext()).has_value()); + + LegacyNormSpecDrawPacket large_translation = *packet; + large_translation.mModelMatrix[0] = 0.8f; + large_translation.mModelMatrix[1] = 0.6f; + large_translation.mModelMatrix[4] = -0.6f; + large_translation.mModelMatrix[5] = 0.8f; + large_translation.mModelMatrix[12] = 1'000'000.f; + large_translation.mModelMatrix[13] = -1'000'000.f; + ensure("a rotated transform with a large finite translation is accepted", + materializeLegacyNormSpecWorldParameters(large_translation, completeContext()).has_value()); + + LegacyNormSpecDrawPacket projective = *packet; + projective.mModelMatrix[3] = 0.25f; + ensure("a non-affine modelview is rejected", + validLegacyNormSpecDrawPacket(projective) && !materializeLegacyNormSpecWorldParameters(projective, completeContext())); +} + +template<> +template<> +void material_parameter_contract_test_object::test<8>() +{ + auto packet = completePacket(legacyNormSpecModernHDRPipelineKey()); + ensure("overflow packet builds", packet.has_value()); + packet->mModelMatrix[0] = 2.f; + + LegacyNormSpecWorldParameterContext context = completeContext(); + context.mBaseModelview[0] = std::numeric_limits::max(); + ensure("finite modelview factors that overflow are rejected", + std::isfinite(context.mBaseModelview[0]) && std::isfinite(packet->mModelMatrix[0]) && + !materializeLegacyNormSpecWorldParameters(*packet, context)); + + context = completeContext(); + context.mProjection[0] = std::numeric_limits::max(); + ensure("finite projection factors that overflow are rejected", + std::isfinite(context.mProjection[0]) && !materializeLegacyNormSpecWorldParameters(*packet, context)); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendercontract_test.cpp b/indra/llrender/tests/llrendercontract_test.cpp new file mode 100644 index 00000000000..1848c0813b5 --- /dev/null +++ b/indra/llrender/tests/llrendercontract_test.cpp @@ -0,0 +1,765 @@ +/** + * @file llrendercontract_test.cpp + * @brief Tests for the API-neutral renderer contract. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llmaterialcontract.h" +#include "llrendercontract.h" +#include "lltonemapcontract.h" +#include "lltut.h" + +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; + +constexpr ImageHandle SCENE_IMAGE{ 1, 1 }; +constexpr ImageHandle EXPOSURE_IMAGE{ 2, 1 }; +constexpr ImageHandle OUTPUT_IMAGE{ 3, 1 }; +constexpr ImageHandle OLD_STREAMED_IMAGE{ 11, 1 }; +constexpr ImageHandle STREAMED_IMAGE{ 11, 2 }; + +constexpr BufferHandle SCREEN_TRIANGLE_BUFFER{ 3, 1 }; + +constexpr SamplerHandle POINT_SAMPLER{ 1, 1 }; +constexpr SamplerHandle LINEAR_SAMPLER{ 2, 1 }; + +constexpr PipelineHandle TONEMAP_PIPELINE{ 1, 1 }; +constexpr PipelineHandle TEXTURE_PIPELINE{ 3, 1 }; + +ByteRange bytes(std::size_t size) +{ + auto storage = std::make_shared>(size, 0); + return { storage, 0, size }; +} + +ImageResource image(ImageHandle handle, std::uint32_t width, std::uint32_t height, PixelFormat format, + ResourceLifetime lifetime = ResourceLifetime::Persistent, std::uint32_t mip_levels = 1) +{ + ImageResource resource; + resource.mHandle = handle; + resource.mExtent = { width, height }; + resource.mFormat = format; + resource.mLifetime = lifetime; + resource.mMipLevels = mip_levels; + return resource; +} + +SamplerResource sampler(SamplerHandle handle, Filter filter, MipFilter mip_filter = MipFilter::Disabled, float max_anisotropy = 1.f, + AddressMode address = AddressMode::Clamp) +{ + SamplerResource resource; + resource.mHandle = handle; + resource.mMinFilter = filter; + resource.mMagFilter = filter; + resource.mMipFilter = mip_filter; + resource.mMaxAnisotropy = max_anisotropy; + resource.mAddressU = address; + resource.mAddressV = address; + return resource; +} + +RenderPass pass(PassId id, std::uint32_t width, std::uint32_t height) +{ + RenderPass result; + result.mId = id; + result.mExtent = { width, height }; + result.mViewport = { 0.f, 0.f, static_cast(width), static_cast(height), 0.f, 1.f }; + result.mScissor = { 0, 0, width, height }; + return result; +} + +FrameSnapshot fullScreenFrame() +{ + TonemapInputs inputs; + inputs.mFrame = 17; + inputs.mHandles = { SCREEN_TRIANGLE_BUFFER, SCENE_IMAGE, EXPOSURE_IMAGE, OUTPUT_IMAGE, + POINT_SAMPLER, LINEAR_SAMPLER, TONEMAP_PIPELINE, { 1 } }; + inputs.mSourceExtent = { 1280, 720 }; + inputs.mDestinationExtent = { 1280, 720 }; + inputs.mParameters = { 1.25f, 0.7f, 1, 1.8f }; + return *buildTonemapFrame(inputs); +} + +FrameSnapshot materialFrame() +{ + MaterialInputs inputs; + inputs.mFrame = 23; + inputs.mParameters.mEnvironmentIntensity = 0.625f; + return *buildMaterialFrame(inputs); +} + +FrameSnapshot streamingUploadFrame() +{ + FrameSnapshot frame; + frame.mFrame = 31; + ImageResource old_streamed_image = image(OLD_STREAMED_IMAGE, 4, 4, PixelFormat::RGBA8Unorm); + old_streamed_image.mMipLevels = 3; + ImageResource streamed_image = image(STREAMED_IMAGE, 4, 4, PixelFormat::RGBA8Unorm); + streamed_image.mMipLevels = 3; + frame.mImages = { old_streamed_image, streamed_image, image(OUTPUT_IMAGE, 4, 4, PixelFormat::RGBA8Unorm, ResourceLifetime::External) }; + frame.mSamplers.push_back(sampler(LINEAR_SAMPLER, Filter::Linear, MipFilter::Linear)); + + PipelineResource pipeline; + pipeline.mHandle = TEXTURE_PIPELINE; + pipeline.mProgram = { "contract.sample-texture", 0 }; + pipeline.mColorTargets.push_back({ PixelFormat::RGBA8Unorm, false, 0xf }); + pipeline.mSampledImageBindings = { 0 }; + frame.mPipelines.push_back(pipeline); + + TextureUpload upload; + upload.mDestination = STREAMED_IMAGE; + upload.mRevision = 7; + upload.mExtent = { 4, 4 }; + upload.mLogicalExtent = { 16, 16 }; + upload.mResidentDiscard = 2; + upload.mSourceFormat = PixelFormat::RGBA8Unorm; + upload.mRowPitch = 16; + upload.mPixels = bytes(64); + upload.mMipGeneration = MipGeneration::GenerateRemaining; + frame.mUploads.push_back(upload); + + RenderPass sample = pass({ 1 }, 4, 4); + sample.mLabel = "sample streamed image"; + sample.mImageAccesses = { { STREAMED_IMAGE, + { 0, 3, 0, 1 }, + ImageAccessKind::SampledRead, + ImageState::ShaderRead, + ImageState::ShaderRead, + ImageState::ShaderRead }, + { OUTPUT_IMAGE, + {}, + ImageAccessKind::ColorAttachmentWrite, + ImageState::Undefined, + ImageState::ColorAttachment, + ImageState::ShaderRead } }; + sample.mColorAttachments.push_back({ OUTPUT_IMAGE, {}, LoadOp::DontCare, StoreOp::Store, {} }); + + Draw draw; + draw.mResources.mPipeline = TEXTURE_PIPELINE; + draw.mResources.mSampledImages.push_back({ 0, STREAMED_IMAGE, { 0, 3, 0, 1 }, LINEAR_SAMPLER }); + draw.mVertexCount = 3; + sample.mDraws.emplace_back(std::move(draw)); + frame.mPasses.push_back(std::move(sample)); + frame.mReleases.push_back({ ResourceHandle{ OLD_STREAMED_IMAGE }, frame.mFrame }); + return frame; +} + +bool hasError(const ValidationResult& result, ValidationCode code) +{ + return std::find_if(result.mErrors.begin(), result.mErrors.end(), + [code](const ValidationError& error) { return error.mCode == code; }) != result.mErrors.end(); +} + +} // namespace + +namespace tut +{ +struct render_contract_test +{ +}; + +using render_contract_test_group = test_group; +using render_contract_test_object = render_contract_test_group::object; +render_contract_test_group render_contract_tests("render contract"); + +template<> +template<> +void render_contract_test_object::test<1>() +{ + static_assert(!std::is_same_v); + static_assert(!std::is_same_v); + static_assert(!std::is_same_v); + + ensure("full-screen pass validates", static_cast(validate(fullScreenFrame()))); + ensure("indexed material draw validates", static_cast(validate(materialFrame()))); + ensure("streaming texture upload validates", static_cast(validate(streamingUploadFrame()))); +} + +template<> +template<> +void render_contract_test_object::test<2>() +{ + FrameSnapshot frame = fullScreenFrame(); + frame.mPasses[0].mImageAccesses.pop_back(); + const ValidationResult result = validate(frame); + ensure("full-screen attachment without an explicit write is rejected", hasError(result, ValidationCode::MissingAccess)); +} + +template<> +template<> +void render_contract_test_object::test<3>() +{ + FrameSnapshot frame = materialFrame(); + DrawIndexed& draw = std::get(frame.mPasses[0].mDraws[0]); + draw.mFirstIndex = 100; + draw.mIndexCount = 100; + const ValidationResult result = validate(frame); + ensure("indexed material range beyond the index buffer is rejected", hasError(result, ValidationCode::OutOfBounds)); +} + +template<> +template<> +void render_contract_test_object::test<4>() +{ + FrameSnapshot frame = streamingUploadFrame(); + frame.mUploads[0].mPixels.mSize = 63; + const ValidationResult result = validate(frame); + ensure("undersized streaming payload is rejected", hasError(result, ValidationCode::OutOfBounds)); +} + +template<> +template<> +void render_contract_test_object::test<5>() +{ + FrameSnapshot frame = fullScreenFrame(); + Draw& draw = std::get(frame.mPasses[0].mDraws[0]); + draw.mResources.mSampledImages[0].mImage.mGeneration++; + const ValidationResult result = validate(frame); + ensure("stale sampled-image generation is rejected", hasError(result, ValidationCode::MissingResource)); +} + +template<> +template<> +void render_contract_test_object::test<6>() +{ + FrameSnapshot frame = streamingUploadFrame(); + frame.mReleases[0].mFrame = frame.mFrame - 1; + const ValidationResult result = validate(frame); + ensure("release before frame completion is rejected", hasError(result, ValidationCode::InvalidRelease)); +} + +template<> +template<> +void render_contract_test_object::test<7>() +{ + FrameSnapshot frame = materialFrame(); + frame.mPasses[0].mDepthAttachment->mSubresource.mMipLevel = std::numeric_limits::max(); + frame.mPasses[0].mImageAccesses.back().mRange.mBaseMipLevel = std::numeric_limits::max(); + const ValidationResult result = validate(frame); + ensure("invalid depth subresource is rejected without traversing it", hasError(result, ValidationCode::OutOfBounds)); +} + +template<> +template<> +void render_contract_test_object::test<8>() +{ + FrameSnapshot frame = materialFrame(); + DrawIndexed& draw = std::get(frame.mPasses[0].mDraws[0]); + draw.mResources.mParameters[0].mBytes.mSize = 1; + const ValidationResult result = validate(frame); + ensure("parameter size must match its pipeline layout", hasError(result, ValidationCode::InvalidBinding)); +} + +template<> +template<> +void render_contract_test_object::test<9>() +{ + FrameSnapshot frame = materialFrame(); + DrawIndexed& draw = std::get(frame.mPasses[0].mDraws[0]); + draw.mIndexBuffer.mType = static_cast(99); + const ValidationResult result = validate(frame); + ensure("index type must be inside the contract domain", hasError(result, ValidationCode::InvalidBinding)); +} + +template<> +template<> +void render_contract_test_object::test<10>() +{ + FrameSnapshot frame = fullScreenFrame(); + frame.mPasses[0].mImageAccesses[0].mKind = static_cast(99); + const ValidationResult result = validate(frame); + ensure("image access kind must be inside the contract domain", hasError(result, ValidationCode::InvalidState)); +} + +template<> +template<> +void render_contract_test_object::test<11>() +{ + FrameSnapshot frame = streamingUploadFrame(); + frame.mUploads[0].mResidentDiscard = 0; + const ValidationResult result = validate(frame); + ensure("logical extent and resident discard must agree", hasError(result, ValidationCode::InvalidUpload)); +} + +template<> +template<> +void render_contract_test_object::test<12>() +{ + FrameSnapshot frame = streamingUploadFrame(); + frame.mPasses[0].mImageAccesses[0].mRange.mMipLevelCount = 1; + const ValidationResult result = validate(frame); + ensure("sampled mip range must be covered by an explicit access", hasError(result, ValidationCode::MissingAccess)); +} + +template<> +template<> +void render_contract_test_object::test<13>() +{ + FrameSnapshot frame = materialFrame(); + DrawIndexed& draw = std::get(frame.mPasses[0].mDraws[0]); + draw.mIndexBuffer.mOffset = 1; + const ValidationResult result = validate(frame); + ensure("index offset must align to the index type", hasError(result, ValidationCode::InvalidBinding)); +} + +template<> +template<> +void render_contract_test_object::test<14>() +{ + FrameSnapshot frame = fullScreenFrame(); + frame.mPasses[0].mColorAttachments[0].mStore = StoreOp::DontCare; + const ValidationResult result = validate(frame); + ensure("discarded attachment contents cannot be published as readable", hasError(result, ValidationCode::InvalidState)); +} + +template<> +template<> +void render_contract_test_object::test<15>() +{ + FrameSnapshot frame = fullScreenFrame(); + frame.mPasses[0].mDependencies.push_back({ 2 }); + const ValidationResult result = validate(frame); + ensure("pass dependencies must name earlier passes", hasError(result, ValidationCode::InvalidDependency)); +} + +template<> +template<> +void render_contract_test_object::test<16>() +{ + constexpr std::array variants{ TonemapVariant::Deferred, TonemapVariant::NoPost, + TonemapVariant::GammaCorrect, TonemapVariant::NoPostGammaCorrect, + TonemapVariant::LegacyGammaCorrect, TonemapVariant::NoPostLegacyGammaCorrect }; + constexpr std::array formats{ PixelFormat::RGBA8Unorm, PixelFormat::RGBA16Float }; + + for (TonemapVariant variant : variants) + { + for (PixelFormat format : formats) + { + TonemapInputs inputs; + inputs.mFrame = 91; + inputs.mSourceExtent = { 17, 9 }; + inputs.mDestinationExtent = { 13, 7 }; + inputs.mDestinationFormat = format; + inputs.mVariant = variant; + inputs.mParameters = { 1.25f, 0.65f, 1, 1.8f }; + + auto frame = buildTonemapFrame(inputs); + ensure("every compiled tonemap variant and output format builds", frame.has_value()); + inputs.mParameters.mExposure = 3.f; + auto decoded = decodeTonemapFrame(*frame); + ensure("canonical tonemap packet decodes", decoded.has_value()); + ensure("builder owns parameter bytes", decoded->mParameters.mExposure == 1.25f); + ensure("source extent survives", decoded->mSourceExtent.mWidth == 17 && decoded->mSourceExtent.mHeight == 9); + ensure("destination extent survives", decoded->mDestinationExtent.mWidth == 13 && decoded->mDestinationExtent.mHeight == 7); + ensure("variant survives", decoded->mVariant == variant); + ensure("output format survives", decoded->mDestinationFormat == format); + ensure("gamma survives", decoded->mParameters.mGamma == 1.8f); + ensure("trace uses mirrored addressing", + frame->mSamplers[0].mAddressU == AddressMode::Mirror && frame->mSamplers[1].mAddressV == AddressMode::Mirror); + ensure("trace keeps disabled depth compare", frame->mPipelines[0].mDepthCompare == CompareOp::LessOrEqual); + } + } +} + +template<> +template<> +void render_contract_test_object::test<17>() +{ + TonemapInputs inputs; + inputs.mFrame = 1; + inputs.mSourceExtent = { 4, 4 }; + inputs.mDestinationExtent = { 4, 4 }; + + inputs.mVariant = static_cast(4); + ensure("legacy gamma without gamma correction is rejected", !buildTonemapFrame(inputs)); + + inputs.mVariant = TonemapVariant::Deferred; + inputs.mDestinationFormat = PixelFormat::RGB8Unorm; + ensure("unsupported output format is rejected", !buildTonemapFrame(inputs)); + + inputs.mDestinationFormat = PixelFormat::RGBA16Float; + inputs.mParameters.mGamma = std::numeric_limits::quiet_NaN(); + ensure("non-finite parameters are rejected", !buildTonemapFrame(inputs)); +} + +template<> +template<> +void render_contract_test_object::test<18>() +{ + FrameSnapshot frame = fullScreenFrame(); + frame.mPipelines[0].mProgram.mName = "unknown.tonemap"; + ensure("decoder rejects an unknown program", !decodeTonemapFrame(frame)); + + frame = fullScreenFrame(); + frame.mPasses[0].mScissor.mWidth--; + ensure("decoder rejects a partial scissor", !decodeTonemapFrame(frame)); + + frame = fullScreenFrame(); + frame.mSamplers[0].mAddressU = AddressMode::Clamp; + ensure("decoder rejects state that diverges from the trace", !decodeTonemapFrame(frame)); +} + +template<> +template<> +void render_contract_test_object::test<19>() +{ + MaterialInputs inputs; + inputs.mFrame = 41; + inputs.mParameters.mSpecularColor = { 0.2f, 0.4f, 0.6f, 0.8f }; + inputs.mParameters.mMirror = 1.f; + + auto frame = buildMaterialFrame(inputs); + ensure("canonical material packet builds", frame.has_value()); + inputs.mParameters.mMirror = 0.f; + auto decoded = decodeMaterialFrame(*frame); + ensure("canonical material packet decodes", decoded.has_value()); + ensure("material builder owns the complete parameter block", + decoded->mParameters.mMirror == 1.f && decoded->mParameters.mSpecularColor == std::array{ 0.2f, 0.4f, 0.6f, 0.8f }); + ensure("material packet uses the complete 272-byte parameter layout", + frame->mPipelines[0].mParameterBindings[0].mSize == sizeof(MaterialParameters) && + std::get(frame->mPasses[0].mDraws[0]).mResources.mParameters[0].mBytes.mSize == sizeof(MaterialParameters)); + ensure("material packet uses packed viewer buffer sizes", + frame->mBuffers[0].mSize == MATERIAL_VERTEX_BUFFER_SIZE && frame->mBuffers[1].mSize == MATERIAL_INDEX_BUFFER_SIZE); + const auto& bindings = std::get(frame->mPasses[0].mDraws[0]).mResources.mVertexBuffers; + ensure("material packet uses packed color and tangent offsets", + bindings[3].mOffset == MATERIAL_COLOR_OFFSET && bindings[4].mOffset == MATERIAL_TANGENT_OFFSET); + ensure("material packet fixes an 8 by 8 target and three input mips", + frame->mPasses[0].mExtent.mWidth == 8 && frame->mPasses[0].mExtent.mHeight == 8 && frame->mImages[0].mExtent.mWidth == 4 && + frame->mImages[0].mMipLevels == 3); +} + +template<> +template<> +void render_contract_test_object::test<20>() +{ + FrameSnapshot frame = materialFrame(); + frame.mPipelines[0].mProgram.mVariant = 1; + ensure("material decoder rejects another shader variant", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + std::swap(frame.mImages[0], frame.mImages[1]); + ensure("material decoder rejects reordered image declarations", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + DrawIndexed& draw = std::get(frame.mPasses[0].mDraws[0]); + std::swap(draw.mResources.mVertexBuffers[0], draw.mResources.mVertexBuffers[1]); + ensure("material decoder rejects reordered vertex bindings", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPasses[0].mDepthAttachment->mLoad = LoadOp::Clear; + ensure("material decoder rejects another depth load operation", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPasses.push_back(frame.mPasses.front()); + frame.mPasses.back().mId = { 2 }; + ensure("material decoder rejects an extra pass", !decodeMaterialFrame(frame)); +} + +template<> +template<> +void render_contract_test_object::test<21>() +{ + MaterialInputs inputs; + inputs.mFrame = 1; + inputs.mParameters.mClipPlane[2] = std::numeric_limits::quiet_NaN(); + ensure("material builder rejects non-finite parameters", !buildMaterialFrame(inputs)); + + inputs.mParameters = {}; + inputs.mParameters.mSpecularColor[1] = 1.01f; + ensure("material builder rejects a specular component outside [0, 1]", !buildMaterialFrame(inputs)); + + inputs.mParameters = {}; + inputs.mParameters.mSpecularColor[3] = -0.01f; + ensure("material builder rejects a negative specular component", !buildMaterialFrame(inputs)); + + inputs.mParameters = {}; + inputs.mParameters.mEnvironmentIntensity = -0.01f; + ensure("material builder rejects environment intensity outside [0, 1]", !buildMaterialFrame(inputs)); + + inputs.mParameters = {}; + inputs.mParameters.mEnvironmentIntensity = 1.01f; + ensure("material builder rejects environment intensity above one", !buildMaterialFrame(inputs)); + + inputs.mParameters = {}; + inputs.mParameters.mEmissiveBrightness = 0.5f; + ensure("material builder requires binary emissive state", !buildMaterialFrame(inputs)); + + inputs.mParameters = {}; + inputs.mParameters.mMirror = 0.5f; + ensure("material builder requires binary mirror state", !buildMaterialFrame(inputs)); + + MaterialInputs aliased_buffers; + aliased_buffers.mFrame = 1; + aliased_buffers.mHandles.mIndexBuffer = { aliased_buffers.mHandles.mVertexBuffer.mIndex, + aliased_buffers.mHandles.mVertexBuffer.mGeneration + 1 }; + ensure("material builder rejects buffer indices aliased across generations", !buildMaterialFrame(aliased_buffers)); + + constexpr std::array image_handles{ &MaterialHandles::mDiffuse, &MaterialHandles::mNormal, &MaterialHandles::mSpecular, + &MaterialHandles::mGBuffer0, &MaterialHandles::mGBuffer1, &MaterialHandles::mGBuffer2, + &MaterialHandles::mDepth }; + for (std::size_t image = 1; image < image_handles.size(); ++image) + { + MaterialInputs aliased_images; + aliased_images.mFrame = 1; + aliased_images.mHandles.* + image_handles[image] = { aliased_images.mHandles.mDiffuse.mIndex, + aliased_images.mHandles.mDiffuse.mGeneration + static_cast(image) }; + ensure("material builder rejects image indices aliased across generations", !buildMaterialFrame(aliased_images)); + } + + FrameSnapshot aliased_buffer_frame = materialFrame(); + BufferHandle aliased_index{ aliased_buffer_frame.mBuffers[0].mHandle.mIndex, aliased_buffer_frame.mBuffers[0].mHandle.mGeneration + 1 }; + aliased_buffer_frame.mBuffers[1].mHandle = aliased_index; + aliased_buffer_frame.mPasses[0].mBufferAccesses[1].mBuffer = aliased_index; + std::get(aliased_buffer_frame.mPasses[0].mDraws[0]).mIndexBuffer.mBuffer = aliased_index; + ensure("material decoder rejects buffer indices aliased across generations", !decodeMaterialFrame(aliased_buffer_frame)); + + FrameSnapshot aliased_image_frame = materialFrame(); + ImageHandle aliased_normal{ aliased_image_frame.mImages[0].mHandle.mIndex, aliased_image_frame.mImages[0].mHandle.mGeneration + 1 }; + aliased_image_frame.mImages[1].mHandle = aliased_normal; + aliased_image_frame.mPasses[0].mImageAccesses[1].mImage = aliased_normal; + std::get(aliased_image_frame.mPasses[0].mDraws[0]).mResources.mSampledImages[1].mImage = aliased_normal; + ensure("material decoder rejects image indices aliased across generations", !decodeMaterialFrame(aliased_image_frame)); + + FrameSnapshot frame = materialFrame(); + frame.mPipelines[0].mParameterBindings[0].mSize = 160; + DrawIndexed& draw = std::get(frame.mPasses[0].mDraws[0]); + draw.mResources.mParameters[0].mBytes = bytes(160); + ensure("material decoder rejects the old 160-byte placeholder", !decodeMaterialFrame(frame)); +} + +template<> +template<> +void render_contract_test_object::test<22>() +{ + for (std::size_t binding = 0; binding < 7; ++binding) + { + FrameSnapshot frame = materialFrame(); + DrawIndexed& draw = std::get(frame.mPasses[0].mDraws[0]); + ++draw.mResources.mVertexBuffers[binding].mOffset; + ensure("each packed vertex offset is canonical", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + ++frame.mPipelines[0].mVertexBindings[binding].mStride; + ensure("each planar vertex stride is canonical", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPipelines[0].mVertexAttributes[binding].mBinding = static_cast((binding + 1) % 7); + ensure("each material vertex attribute binding is canonical", !decodeMaterialFrame(frame)); + } +} + +template<> +template<> +void render_contract_test_object::test<23>() +{ + FrameSnapshot frame = materialFrame(); + frame.mBuffers.pop_back(); + ensure("material decoder rejects a missing resource declaration", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + std::swap(frame.mBuffers[0], frame.mBuffers[1]); + ensure("material decoder rejects reordered buffer declarations", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mImages[0].mMipLevels = 2; + ensure("material decoder rejects a shorter texture mip chain", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mImages[1].mExtent.mWidth = 8; + ensure("material decoder rejects another sampled texture extent", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mSamplers[0].mMaxAnisotropy = 4.f; + ensure("material decoder rejects another anisotropy", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mSamplers[0].mAddressV = AddressMode::Clamp; + ensure("material decoder rejects another texture address mode", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + DrawIndexed& draw = std::get(frame.mPasses[0].mDraws[0]); + std::swap(draw.mResources.mSampledImages[0], draw.mResources.mSampledImages[1]); + ensure("material decoder rejects reordered sampled bindings", !decodeMaterialFrame(frame)); +} + +template<> +template<> +void render_contract_test_object::test<24>() +{ + constexpr std::array COLOR_IMAGES{ 3, 4, 5 }; + for (std::size_t image_index : COLOR_IMAGES) + { + FrameSnapshot frame = materialFrame(); + frame.mImages[image_index].mFormat = PixelFormat::RGB8Unorm; + ensure("every G-buffer storage format is canonical", !decodeMaterialFrame(frame)); + } + + for (std::size_t attachment = 0; attachment < 3; ++attachment) + { + FrameSnapshot frame = materialFrame(); + frame.mPasses[0].mColorAttachments[attachment].mLoad = LoadOp::DontCare; + ensure("every G-buffer load operation is canonical", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPasses[0].mColorAttachments[attachment].mStore = StoreOp::DontCare; + ensure("every G-buffer store operation is canonical", !decodeMaterialFrame(frame)); + } + + FrameSnapshot frame = materialFrame(); + frame.mImages[6].mFormat = PixelFormat::Depth32Float; + frame.mPipelines[0].mDepthFormat = PixelFormat::Depth32Float; + ensure("material decoder rejects another depth format", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPasses[0].mDepthAttachment->mStore = StoreOp::DontCare; + ensure("material decoder rejects another depth store operation", !decodeMaterialFrame(frame)); +} + +template<> +template<> +void render_contract_test_object::test<25>() +{ + FrameSnapshot frame = materialFrame(); + DrawIndexed& first_draw = std::get(frame.mPasses[0].mDraws[0]); + first_draw.mIndexCount = 5; + ensure("material decoder rejects another index count", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + DrawIndexed& second_draw = std::get(frame.mPasses[0].mDraws[0]); + second_draw.mFirstIndex = 1; + second_draw.mIndexCount = 5; + ensure("material decoder rejects another first index", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + DrawIndexed& third_draw = std::get(frame.mPasses[0].mDraws[0]); + third_draw.mMaxVertex = 2; + ensure("material decoder rejects another declared vertex range", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + DrawIndexed& fourth_draw = std::get(frame.mPasses[0].mDraws[0]); + fourth_draw.mFirstInstance = 1; + ensure("material decoder rejects another instance range", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + DrawIndexed& fifth_draw = std::get(frame.mPasses[0].mDraws[0]); + auto larger_storage = std::make_shared>(sizeof(MaterialParameters) + 1, 0); + fifth_draw.mResources.mParameters[0].mBytes = { larger_storage, 1, sizeof(MaterialParameters) }; + ensure("material decoder rejects a non-canonical parameter byte range", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + DrawIndexed& sixth_draw = std::get(frame.mPasses[0].mDraws[0]); + sixth_draw.mResources.mParameters[0].mBinding = 1; + frame.mPipelines[0].mParameterBindings[0].mBinding = 1; + ensure("material decoder rejects another parameter binding", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPasses[0].mDraws.push_back(frame.mPasses[0].mDraws.front()); + ensure("material decoder rejects an extra indexed draw", !decodeMaterialFrame(frame)); +} + +template<> +template<> +void render_contract_test_object::test<26>() +{ + FrameSnapshot frame = materialFrame(); + frame.mPasses[0].mViewport.mX = 1.f; + ensure("material decoder rejects another viewport", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + --frame.mPasses[0].mScissor.mWidth; + ensure("material decoder rejects another scissor", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPipelines[0].mCullMode = CullMode::Disabled; + ensure("material decoder rejects disabled culling", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPipelines[0].mFrontFace = FrontFace::Clockwise; + ensure("material decoder rejects another front face", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPipelines[0].mDepthCompare = CompareOp::AlwaysPass; + ensure("material decoder rejects another depth comparison", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPipelines[0].mColorTargets[1].mWriteMask = 0x7; + ensure("material decoder rejects a partial G-buffer write mask", !decodeMaterialFrame(frame)); + + frame = materialFrame(); + frame.mPasses[0].mLabel = "other material"; + ensure("material decoder rejects another pass identity", !decodeMaterialFrame(frame)); +} + +template<> +template<> +void render_contract_test_object::test<27>() +{ + FrameSnapshot increasing = streamingUploadFrame(); + TextureUpload next = increasing.mUploads.front(); + next.mRevision = 8; + next.mMipGeneration = MipGeneration::Disabled; + next.mBefore = ImageState::ShaderRead; + increasing.mUploads.push_back(next); + ensure("upload revisions may increase for one exact destination", static_cast(validate(increasing))); + + FrameSnapshot equal = streamingUploadFrame(); + next = equal.mUploads.front(); + next.mMipGeneration = MipGeneration::Disabled; + next.mBefore = ImageState::ShaderRead; + equal.mUploads.push_back(next); + ensure("equal upload revisions are rejected for one exact destination", hasError(validate(equal), ValidationCode::InvalidUpload)); + + FrameSnapshot decreasing = streamingUploadFrame(); + next = decreasing.mUploads.front(); + next.mRevision = 6; + next.mMipGeneration = MipGeneration::Disabled; + next.mBefore = ImageState::ShaderRead; + decreasing.mUploads.push_back(next); + ensure("decreasing upload revisions are rejected for one exact destination", + hasError(validate(decreasing), ValidationCode::InvalidUpload)); + + FrameSnapshot another_generation = streamingUploadFrame(); + next = another_generation.mUploads.front(); + next.mDestination = OLD_STREAMED_IMAGE; + another_generation.mUploads.push_back(next); + ensure("upload revisions are independent across image generations", static_cast(validate(another_generation))); +} + +template<> +template<> +void render_contract_test_object::test<28>() +{ + FrameSnapshot frame = streamingUploadFrame(); + for (ImageResource& image_resource : frame.mImages) + { + image_resource.mFormat = PixelFormat::RGB16Float; + } + frame.mPipelines[0].mColorTargets[0].mFormat = PixelFormat::RGB16Float; + frame.mUploads[0].mSourceFormat = PixelFormat::RGB16Float; + frame.mUploads[0].mRowPitch = 24; + frame.mUploads[0].mPixels = bytes(96); + ensure("RGB16Float is a valid color target and six-byte upload format", static_cast(validate(frame))); + + frame.mUploads[0].mRowPitch = 23; + ensure("RGB16Float upload rows require six bytes per pixel", hasError(validate(frame), ValidationCode::InvalidUpload)); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrenderglmaterialregistry_test.cpp b/indra/llrender/tests/llrenderglmaterialregistry_test.cpp new file mode 100644 index 00000000000..c0cbec01c58 --- /dev/null +++ b/indra/llrender/tests/llrenderglmaterialregistry_test.cpp @@ -0,0 +1,114 @@ +/** + * @file llrenderglmaterialregistry_test.cpp + * @brief Tests for generation-safe material OpenGL object resolution. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrenderglmaterial.h" +#include "lltut.h" + +#include +#include + +namespace tut +{ + +struct render_gl_material_registry_test +{ +}; + +using render_gl_material_registry_group = test_group; +using render_gl_material_registry_object = render_gl_material_registry_group::object; +render_gl_material_registry_group render_gl_material_registry_tests("render GL material registry"); + +template<> +template<> +void render_gl_material_registry_object::test<1>() +{ + using namespace LLRenderContract; + using namespace LLRenderGLMaterial; + + Registry registry; + auto* vertex_buffer = reinterpret_cast(std::uintptr_t{ 0x10 }); + auto* target = reinterpret_cast(std::uintptr_t{ 0x20 }); + auto* shader = reinterpret_cast(std::uintptr_t{ 0x30 }); + + ensure("paired buffer registration succeeds", registry.addVertexBuffer({ 1, 4 }, { 2, 8 }, vertex_buffer)); + ensure("diffuse registration succeeds", registry.addSampledImage({ 3, 2 }, { 101, PixelFormat::RGBA8Unorm, { 4, 4 }, 3 })); + ensure("normal registration succeeds", registry.addSampledImage({ 4, 3 }, { 102, PixelFormat::RGBA8Unorm, { 4, 4 }, 3 })); + ensure("specular registration succeeds", registry.addSampledImage({ 5, 5 }, { 103, PixelFormat::RGBA8Unorm, { 4, 4 }, 3 })); + ensure("target registration succeeds", + registry.addRenderTarget({ ImageHandle{ 6, 1 }, ImageHandle{ 7, 2 }, ImageHandle{ 8, 3 } }, { 9, 4 }, target)); + ensure("sampler registration succeeds", registry.addSampler({ 10, 6 }, {})); + ensure("pipeline registration succeeds", registry.addPipeline({ 11, 7 }, { "deferred.material.normspec", 0 }, shader)); + + ensure("exact vertex and index generations resolve", registry.resolveVertexBuffer({ 1, 4 }, { 2, 8 }) == vertex_buffer); + ensure("either exact buffer handle resolves to the shared object", registry.resolve(BufferHandle{ 2, 8 }) == vertex_buffer); + const SampledImage* diffuse = registry.resolveSampledImage({ 3, 2 }); + ensure("exact sampled image generation resolves", diffuse && diffuse->mTexture == 101 && diffuse->mMipLevels == 3); + + const TargetImage color = registry.resolveTargetImage({ 7, 2 }); + ensure("color handle resolves to the exact target attachment", + color.mTarget == target && color.mAspect == TargetAspect::Color && color.mAttachment == 1); + const TargetImage depth = registry.resolveTargetImage({ 9, 4 }); + ensure("depth handle resolves to the target depth aspect", depth.mTarget == target && depth.mAspect == TargetAspect::Depth); + ensure("exact sampler generation resolves", registry.resolve(SamplerHandle{ 10, 6 }) != nullptr); + ensure("exact pipeline key resolves", registry.resolve(PipelineHandle{ 11, 7 }, { "deferred.material.normspec", 0 }) == shader); +} + +template<> +template<> +void render_gl_material_registry_object::test<2>() +{ + using namespace LLRenderContract; + using namespace LLRenderGLMaterial; + + Registry registry; + auto* vertex_buffer = reinterpret_cast(std::uintptr_t{ 0x10 }); + auto* target = reinterpret_cast(std::uintptr_t{ 0x20 }); + auto* shader = reinterpret_cast(std::uintptr_t{ 0x30 }); + + ensure("paired buffer registration succeeds", registry.addVertexBuffer({ 1, 4 }, { 2, 8 }, vertex_buffer)); + ensure("sampled image registration succeeds", registry.addSampledImage({ 3, 2 }, { 101, PixelFormat::RGBA8Unorm, { 4, 4 }, 3 })); + ensure("target registration succeeds", + registry.addRenderTarget({ ImageHandle{ 6, 1 }, ImageHandle{ 7, 2 }, ImageHandle{ 8, 3 } }, { 9, 4 }, target)); + ensure("sampler registration succeeds", registry.addSampler({ 10, 6 }, {})); + ensure("pipeline registration succeeds", registry.addPipeline({ 11, 7 }, { "deferred.material.normspec", 0 }, shader)); + + ensure("stale vertex generation is rejected", registry.resolveVertexBuffer({ 1, 3 }, { 2, 8 }) == nullptr); + ensure("stale index generation is rejected", registry.resolveVertexBuffer({ 1, 4 }, { 2, 7 }) == nullptr); + ensure("stale sampled generation is rejected", registry.resolveSampledImage({ 3, 1 }) == nullptr); + ensure("stale output generation is rejected", registry.resolveTargetImage({ 7, 1 }).mTarget == nullptr); + ensure("stale sampler generation is rejected", registry.resolve(SamplerHandle{ 10, 5 }) == nullptr); + ensure("program variant mismatch is rejected", + registry.resolve(PipelineHandle{ 11, 7 }, { "deferred.material.normspec", 12 }) == nullptr); + ensure("program name mismatch is rejected", registry.resolve(PipelineHandle{ 11, 7 }, { "other.material", 0 }) == nullptr); + ensure("stale pipeline generation is rejected", + registry.resolve(PipelineHandle{ 11, 6 }, { "deferred.material.normspec", 0 }) == nullptr); + + ensure("a second buffer pair is rejected", !registry.addVertexBuffer({ 12, 1 }, { 13, 1 }, vertex_buffer)); + ensure("a null buffer object is rejected", !Registry{}.addVertexBuffer({ 1, 1 }, { 2, 1 }, nullptr)); + ensure("a null GL texture is rejected", !Registry{}.addSampledImage({ 1, 1 }, { 0, PixelFormat::RGBA8Unorm, { 4, 4 }, 3 })); + ensure("a sampled image index cannot be reused with a new generation", + !registry.addSampledImage({ 3, 9 }, { 104, PixelFormat::RGBA8Unorm, { 4, 4 }, 3 })); + ensure("a sampled GL texture cannot alias an existing sampled image", + !registry.addSampledImage({ 4, 1 }, { 101, PixelFormat::RGBA8Unorm, { 4, 4 }, 3 })); + ensure("a second sampler is rejected", !registry.addSampler({ 12, 1 }, {})); + Registry alias_registry; + ensure("alias setup sampled image succeeds", alias_registry.addSampledImage({ 3, 1 }, { 201, PixelFormat::RGBA8Unorm, { 4, 4 }, 3 })); + ensure("an output handle cannot alias a sampled handle index", + !alias_registry.addRenderTarget({ ImageHandle{ 3, 2 }, ImageHandle{ 4, 1 }, ImageHandle{ 5, 1 } }, { 6, 1 }, target)); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendergltextureuploadregistry_test.cpp b/indra/llrender/tests/llrendergltextureuploadregistry_test.cpp new file mode 100644 index 00000000000..6f093434c5c --- /dev/null +++ b/indra/llrender/tests/llrendergltextureuploadregistry_test.cpp @@ -0,0 +1,151 @@ +/** + * @file llrendergltextureuploadregistry_test.cpp + * @brief Context-free tests for streamed-upload OpenGL object resolution. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendergltextureupload.h" +#include "lltut.h" + +#include +#include + +namespace tut +{ + +struct render_gl_texture_upload_registry_test +{ +}; + +using render_gl_texture_upload_registry_group = test_group; +using render_gl_texture_upload_registry_object = render_gl_texture_upload_registry_group::object; +render_gl_texture_upload_registry_group render_gl_texture_upload_registry_tests("render GL texture upload registry"); + +template<> +template<> +void render_gl_texture_upload_registry_object::test<1>() +{ + using namespace LLRenderContract; + using namespace LLRenderGLTextureUpload; + + Registry registry; + auto* screen = reinterpret_cast(std::uintptr_t{ 0x10 }); + auto* old_image = reinterpret_cast(std::uintptr_t{ 0x20 }); + auto* replacement = reinterpret_cast(std::uintptr_t{ 0x30 }); + auto* output = reinterpret_cast(std::uintptr_t{ 0x40 }); + auto* shader = reinterpret_cast(std::uintptr_t{ 0x50 }); + LifecycleLedger ledger{ { 11, 4 }, 22 }; + + ensure("screen registration succeeds", registry.addScreenTriangle({ 1, 7 }, screen)); + ensure("consecutive image generations register", + registry.addImageGenerations({ 11, 4 }, old_image, { 11, 5 }, replacement)); + ensure("output registration succeeds", registry.addOutput({ 12, 3 }, output)); + ensure("canonical GL sampler registration succeeds", registry.addSampler({ 2, 8 }, { 91 })); + ensure("canonical pipeline registration succeeds", + registry.addPipeline({ 3, 9 }, { "contract.sample-texture", 0 }, shader)); + ensure("lifecycle registration succeeds", registry.addLifecycle(&ledger)); + + ensure("exact screen generation resolves", registry.resolve(BufferHandle{ 1, 7 }) == screen); + ensure("exact old generation resolves", registry.resolveRegisteredImage({ 11, 4 }) == old_image); + ensure("exact replacement generation resolves", registry.resolveRegisteredImage({ 11, 5 }) == replacement); + ensure("exact output generation resolves", registry.resolveOutput({ 12, 3 }) == output); + ensure("exact sampler generation resolves", registry.resolve(SamplerHandle{ 2, 8 }) != nullptr); + ensure("exact pipeline key resolves", + registry.resolve(PipelineHandle{ 3, 9 }, { "contract.sample-texture", 0 }) == shader); + ensure("registry borrows the ledger", registry.lifecycle() == &ledger); + ensure("only the published old generation is logically resolvable", registry.isResolvable({ 11, 4 })); + ensure("replacement is registered but not published", !registry.isResolvable({ 11, 5 })); +} + +template<> +template<> +void render_gl_texture_upload_registry_object::test<2>() +{ + using namespace LLRenderContract; + using namespace LLRenderGLTextureUpload; + + auto* screen = reinterpret_cast(std::uintptr_t{ 0x10 }); + auto* old_image = reinterpret_cast(std::uintptr_t{ 0x20 }); + auto* replacement = reinterpret_cast(std::uintptr_t{ 0x30 }); + auto* output = reinterpret_cast(std::uintptr_t{ 0x40 }); + auto* shader = reinterpret_cast(std::uintptr_t{ 0x50 }); + + ensure("null screen is rejected", !Registry{}.addScreenTriangle({ 1, 1 }, nullptr)); + ensure("invalid screen handle is rejected", !Registry{}.addScreenTriangle({}, screen)); + ensure("aliased image objects are rejected", + !Registry{}.addImageGenerations({ 11, 1 }, old_image, { 11, 2 }, old_image)); + ensure("different image indices are rejected", + !Registry{}.addImageGenerations({ 11, 1 }, old_image, { 12, 2 }, replacement)); + ensure("a skipped generation is rejected", + !Registry{}.addImageGenerations({ 11, 1 }, old_image, { 11, 3 }, replacement)); + ensure("generation overflow is rejected", + !Registry{}.addImageGenerations({ 11, std::numeric_limits::max() }, old_image, { 11, 1 }, replacement)); + + Registry output_first; + ensure("output-first setup succeeds", output_first.addOutput({ 11, 9 }, output)); + ensure("image index cannot alias an existing output", + !output_first.addImageGenerations({ 11, 1 }, old_image, { 11, 2 }, replacement)); + Registry images_first; + ensure("image-first setup succeeds", images_first.addImageGenerations({ 11, 1 }, old_image, { 11, 2 }, replacement)); + ensure("output index cannot alias an image generation", !images_first.addOutput({ 11, 7 }, output)); + + ensure("zero sampler name is rejected", !Registry{}.addSampler({ 1, 1 }, {})); + Sampler wrong_filter{ 7 }; + wrong_filter.mMinFilter = Filter::Nearest; + ensure("noncanonical sampler descriptor is rejected", !Registry{}.addSampler({ 1, 1 }, wrong_filter)); + Sampler nonfinite{ 7 }; + nonfinite.mMaxAnisotropy = std::numeric_limits::infinity(); + ensure("nonfinite anisotropy is rejected", !Registry{}.addSampler({ 1, 1 }, nonfinite)); + + ensure("wrong program key is rejected", + !Registry{}.addPipeline({ 1, 1 }, { "interface.copy", 0 }, shader)); + ensure("wrong program variant is rejected", + !Registry{}.addPipeline({ 1, 1 }, { "contract.sample-texture", 1 }, shader)); + ensure("null shader is rejected", + !Registry{}.addPipeline({ 1, 1 }, { "contract.sample-texture", 0 }, nullptr)); + + LifecycleLedger pending{ { 11, 1 }, 22, true }; + ensure("pending completion is rejected", !Registry{}.addLifecycle(&pending)); + LifecycleLedger already_completed{ { 11, 1 }, 22 }; + already_completed.mCompletionCount = 1; + ensure("preexisting completion evidence is rejected", !Registry{}.addLifecycle(&already_completed)); +} + +template<> +template<> +void render_gl_texture_upload_registry_object::test<3>() +{ + using namespace LLRenderContract; + using namespace LLRenderGLTextureUpload; + + Registry registry; + LifecycleLedger ledger{ { 11, 1 }, 22 }; + ensure("lifecycle setup succeeds", registry.addLifecycle(&ledger)); + const LifecycleLedger before = ledger; + + ExecutionResult result = makeTextureUploadArtifact(); + result.mSampledRGBA8 = { 0x5a }; + const ExecutionResult result_before = result; + ensure("an invalid packet fails without requiring a GL context", !execute({}, registry, result)); + ensure("packet rejection leaves the lifecycle ledger unchanged", ledger == before); + ensure("packet rejection leaves the caller result unchanged", result == result_before); + + const TextureUploadCase upload_case = makeTextureUploadCase(); + ensure("a canonical packet with missing borrowed resources fails before GL preflight", + !execute(upload_case.mFrame, registry, result)); + ensure("registry rejection leaves the lifecycle ledger unchanged", ledger == before); + ensure("registry rejection leaves the caller result unchanged", result == result_before); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendergltonemapregistry_test.cpp b/indra/llrender/tests/llrendergltonemapregistry_test.cpp new file mode 100644 index 00000000000..49e07a42c06 --- /dev/null +++ b/indra/llrender/tests/llrendergltonemapregistry_test.cpp @@ -0,0 +1,80 @@ +/** + * @file llrendergltonemapregistry_test.cpp + * @brief Tests for generation-safe tonemap OpenGL object resolution. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendergltonemap.h" +#include "lltut.h" + +#include + +namespace tut +{ + +struct render_gl_tonemap_registry_test +{ +}; + +using render_gl_tonemap_registry_group = test_group; +using render_gl_tonemap_registry_object = render_gl_tonemap_registry_group::object; +render_gl_tonemap_registry_group render_gl_tonemap_registry_tests("render GL tonemap registry"); + +template<> +template<> +void render_gl_tonemap_registry_object::test<1>() +{ + using namespace LLRenderContract; + using namespace LLRenderGLTonemap; + + Registry registry; + auto* buffer = reinterpret_cast(std::uintptr_t{ 0x10 }); + auto* image = reinterpret_cast(std::uintptr_t{ 0x20 }); + auto* shader = reinterpret_cast(std::uintptr_t{ 0x30 }); + + ensure("buffer registration succeeds", registry.addBuffer({ 1, 4 }, buffer)); + ensure("image registration succeeds", registry.addImage({ 2, 7 }, image)); + ensure("sampler registration succeeds", registry.addSampler({ 3, 2 }, Sampler::Point)); + ensure("pipeline registration succeeds", registry.addPipeline({ 4, 9 }, { "deferred.tonemap", 3 }, shader)); + + ensure("exact buffer generation resolves", registry.resolve(BufferHandle{ 1, 4 }) == buffer); + ensure("exact image generation resolves", registry.resolve(ImageHandle{ 2, 7 }) == image); + ensure("exact sampler generation resolves", registry.resolve(SamplerHandle{ 3, 2 }) && + *registry.resolve(SamplerHandle{ 3, 2 }) == Sampler::Point); + ensure("exact pipeline key resolves", registry.resolve(PipelineHandle{ 4, 9 }, { "deferred.tonemap", 3 }) == shader); +} + +template<> +template<> +void render_gl_tonemap_registry_object::test<2>() +{ + using namespace LLRenderContract; + using namespace LLRenderGLTonemap; + + Registry registry; + auto* image = reinterpret_cast(std::uintptr_t{ 0x20 }); + auto* shader = reinterpret_cast(std::uintptr_t{ 0x30 }); + ensure("image registration succeeds", registry.addImage({ 2, 7 }, image)); + ensure("pipeline registration succeeds", registry.addPipeline({ 4, 9 }, { "deferred.tonemap", 3 }, shader)); + + ensure("stale image generation is rejected", registry.resolve(ImageHandle{ 2, 6 }) == nullptr); + ensure("unknown image index is rejected", registry.resolve(ImageHandle{ 8, 7 }) == nullptr); + ensure("program variant mismatch is rejected", registry.resolve(PipelineHandle{ 4, 9 }, { "deferred.tonemap", 2 }) == nullptr); + ensure("program name mismatch is rejected", registry.resolve(PipelineHandle{ 4, 9 }, { "other.tonemap", 3 }) == nullptr); + ensure("stale pipeline generation is rejected", registry.resolve(PipelineHandle{ 4, 8 }, { "deferred.tonemap", 3 }) == nullptr); + ensure("duplicate live index is rejected", !registry.addImage({ 2, 8 }, image)); + ensure("null object is rejected", !registry.addImage({ 9, 1 }, nullptr)); +} + +} diff --git a/indra/llrender/tests/llrendervulkanglobaldispatch_test.cpp b/indra/llrender/tests/llrendervulkanglobaldispatch_test.cpp new file mode 100644 index 00000000000..72116dcb073 --- /dev/null +++ b/indra/llrender/tests/llrendervulkanglobaldispatch_test.cpp @@ -0,0 +1,342 @@ +/** + * @file llrendervulkanglobaldispatch_test.cpp + * @brief Tests for loader-independent Vulkan global command dispatch. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendervulkanglobaldispatch.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderVulkan; + +enum class MissingCommand : std::uint8_t +{ + None, + CreateInstance, + EnumerateInstanceExtensionProperties, + EnumerateInstanceLayerProperties, + EnumerateInstanceVersion +}; + +struct GlobalDispatchState +{ + static constexpr std::size_t MAX_LOOKUPS = 8; + + MissingCommand mMissing = MissingCommand::None; + VkResult mVersionResult = VK_SUCCESS; + std::uint32_t mVersionOutput = VK_API_VERSION_1_2; + std::array mLookupInstances{}; + std::array mLookupNames{}; + std::size_t mLookupCount = 0; + std::size_t mVersionCalls = 0; +}; + +GlobalDispatchState* gGlobalDispatchState = nullptr; + +struct ScopedGlobalDispatchState +{ + explicit ScopedGlobalDispatchState(GlobalDispatchState& state) noexcept { gGlobalDispatchState = &state; } + ~ScopedGlobalDispatchState() noexcept { gGlobalDispatchState = nullptr; } + + ScopedGlobalDispatchState(const ScopedGlobalDispatchState&) = delete; + ScopedGlobalDispatchState& operator=(const ScopedGlobalDispatchState&) = delete; +}; + +VKAPI_ATTR VkResult VKAPI_CALL fakeCreateInstance(const VkInstanceCreateInfo*, const VkAllocationCallbacks*, VkInstance*) noexcept +{ + return VK_SUCCESS; +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeEnumerateInstanceExtensionProperties(const char*, std::uint32_t*, VkExtensionProperties*) noexcept +{ + return VK_SUCCESS; +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeEnumerateInstanceLayerProperties(std::uint32_t*, VkLayerProperties*) noexcept +{ + return VK_SUCCESS; +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeEnumerateInstanceVersion(std::uint32_t* api_version) noexcept +{ + if (!gGlobalDispatchState || !api_version) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + + ++gGlobalDispatchState->mVersionCalls; + *api_version = gGlobalDispatchState->mVersionOutput; + return gGlobalDispatchState->mVersionResult; +} + +template +PFN_vkVoidFunction eraseFunctionType(Function function) noexcept +{ + return reinterpret_cast(function); +} + +VKAPI_ATTR PFN_vkVoidFunction VKAPI_CALL fakeGetInstanceProcAddr(VkInstance instance, const char* name) noexcept +{ + if (!gGlobalDispatchState || !name || gGlobalDispatchState->mLookupCount >= GlobalDispatchState::MAX_LOOKUPS) + { + return nullptr; + } + + const std::size_t index = gGlobalDispatchState->mLookupCount++; + gGlobalDispatchState->mLookupInstances[index] = instance; + gGlobalDispatchState->mLookupNames[index] = name; + + if (std::strcmp(name, "vkCreateInstance") == 0) + { + return gGlobalDispatchState->mMissing == MissingCommand::CreateInstance ? nullptr : eraseFunctionType(fakeCreateInstance); + } + if (std::strcmp(name, "vkEnumerateInstanceExtensionProperties") == 0) + { + return gGlobalDispatchState->mMissing == MissingCommand::EnumerateInstanceExtensionProperties + ? nullptr + : eraseFunctionType(fakeEnumerateInstanceExtensionProperties); + } + if (std::strcmp(name, "vkEnumerateInstanceLayerProperties") == 0) + { + return gGlobalDispatchState->mMissing == MissingCommand::EnumerateInstanceLayerProperties + ? nullptr + : eraseFunctionType(fakeEnumerateInstanceLayerProperties); + } + if (std::strcmp(name, "vkEnumerateInstanceVersion") == 0) + { + return gGlobalDispatchState->mMissing == MissingCommand::EnumerateInstanceVersion ? nullptr + : eraseFunctionType(fakeEnumerateInstanceVersion); + } + return nullptr; +} + +const VulkanGlobalDispatchResolutionError& requireError(const VulkanGlobalDispatchResolutionResult& result) +{ + const auto* error = std::get_if(&result); + tut::ensure("resolution returns an error", error != nullptr); + return *error; +} + +void ensureLookup(const GlobalDispatchState& state, std::size_t index, std::string_view expected_name) +{ + tut::ensure("the expected lookup was made", index < state.mLookupCount); + tut::ensure("the lookup used a null instance", state.mLookupInstances[index] == VK_NULL_HANDLE); + tut::ensure("the lookup used the exact command name", std::string_view(state.mLookupNames[index]) == expected_name); +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_global_dispatch_test +{ +}; + +using render_vulkan_global_dispatch_test_group = test_group; +using render_vulkan_global_dispatch_test_object = render_vulkan_global_dispatch_test_group::object; +render_vulkan_global_dispatch_test_group render_vulkan_global_dispatch_tests("render Vulkan global dispatch"); + +template<> +template<> +void render_vulkan_global_dispatch_test_object::test<1>() +{ + static_assert(RENDERER_VULKAN_API_VERSION == VK_API_VERSION_1_1); + static_assert(!std::is_default_constructible_v); + static_assert(std::is_copy_constructible_v); + static_assert(std::is_nothrow_copy_constructible_v); + static_assert(std::is_move_constructible_v); + static_assert(std::is_nothrow_move_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_assignable_v); + static_assert(std::is_nothrow_destructible_v); + static_assert(noexcept(resolveVulkanGlobalDispatchGeneration(std::declval()))); + static_assert(std::variant_size_v == 2); + static_assert(std::is_same_v, VulkanGlobalDispatchResolutionError>); + static_assert(std::is_same_v, VulkanGlobalDispatchGeneration>); + static_assert(std::is_copy_constructible_v); + static_assert(std::is_nothrow_copy_constructible_v); + static_assert(std::is_move_constructible_v); + static_assert(std::is_nothrow_move_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_assignable_v); + static_assert(std::is_nothrow_destructible_v); + + const VulkanGlobalDispatchResolutionError left{ VulkanGlobalDispatchResolutionCode::VersionQueryFailure, + VulkanGlobalCommand::EnumerateInstanceVersion, VK_ERROR_INITIALIZATION_FAILED, 0 }; + const VulkanGlobalDispatchResolutionError same = left; + const VulkanGlobalDispatchResolutionError different{ VulkanGlobalDispatchResolutionCode::InsufficientApiVersion, + VulkanGlobalCommand::EnumerateInstanceVersion, VK_SUCCESS, VK_API_VERSION_1_0 }; + ensure("equal errors compare equal", left == same); + ensure("different errors compare unequal", !(left == different)); +} + +template<> +template<> +void render_vulkan_global_dispatch_test_object::test<2>() +{ + const auto result = resolveVulkanGlobalDispatchGeneration(nullptr); + const VulkanGlobalDispatchResolutionError& null_error = requireError(result); + ensure("a null resolver has a distinct error", + null_error.mCode == VulkanGlobalDispatchResolutionCode::InvalidGetInstanceProcAddr && !null_error.mCommand && + null_error.mResult == VK_SUCCESS && null_error.mAvailableApiVersion == 0); + + constexpr std::array missing_commands{ MissingCommand::CreateInstance, + MissingCommand::EnumerateInstanceExtensionProperties, + MissingCommand::EnumerateInstanceLayerProperties }; + constexpr std::array reported_commands{ VulkanGlobalCommand::CreateInstance, + VulkanGlobalCommand::EnumerateInstanceExtensionProperties, + VulkanGlobalCommand::EnumerateInstanceLayerProperties }; + constexpr std::array lookup_names{ "vkCreateInstance", "vkEnumerateInstanceExtensionProperties", + "vkEnumerateInstanceLayerProperties" }; + + for (std::size_t missing_index = 0; missing_index < missing_commands.size(); ++missing_index) + { + GlobalDispatchState state; + state.mMissing = missing_commands[missing_index]; + ScopedGlobalDispatchState scope(state); + + const auto result_for_missing = resolveVulkanGlobalDispatchGeneration(fakeGetInstanceProcAddr); + const VulkanGlobalDispatchResolutionError& error = requireError(result_for_missing); + ensure("a required command absence is reported exactly", + error.mCode == VulkanGlobalDispatchResolutionCode::MissingRequiredCommand && + error.mCommand == reported_commands[missing_index] && error.mResult == VK_SUCCESS && error.mAvailableApiVersion == 0); + ensure_equals("resolution stops at the first missing required command", state.mLookupCount, missing_index + 1); + ensure_equals("version is not queried after a required-command failure", state.mVersionCalls, std::size_t{ 0 }); + for (std::size_t lookup_index = 0; lookup_index <= missing_index; ++lookup_index) + { + ensureLookup(state, lookup_index, lookup_names[lookup_index]); + } + } +} + +template<> +template<> +void render_vulkan_global_dispatch_test_object::test<3>() +{ + GlobalDispatchState state; + state.mMissing = MissingCommand::EnumerateInstanceVersion; + ScopedGlobalDispatchState scope(state); + + const auto result = resolveVulkanGlobalDispatchGeneration(fakeGetInstanceProcAddr); + const VulkanGlobalDispatchResolutionError& error = requireError(result); + ensure("an absent optional query uses the mandated Vulkan 1.0 fallback", + error.mCode == VulkanGlobalDispatchResolutionCode::InsufficientApiVersion && + error.mCommand == VulkanGlobalCommand::EnumerateInstanceVersion && error.mResult == VK_SUCCESS && + error.mAvailableApiVersion == VK_API_VERSION_1_0); + ensure_equals("the absent command is not called", state.mVersionCalls, std::size_t{ 0 }); + ensure_equals("all four names are resolved once", state.mLookupCount, std::size_t{ 4 }); + ensureLookup(state, 0, "vkCreateInstance"); + ensureLookup(state, 1, "vkEnumerateInstanceExtensionProperties"); + ensureLookup(state, 2, "vkEnumerateInstanceLayerProperties"); + ensureLookup(state, 3, "vkEnumerateInstanceVersion"); +} + +template<> +template<> +void render_vulkan_global_dispatch_test_object::test<4>() +{ + GlobalDispatchState state; + state.mVersionResult = VK_ERROR_OUT_OF_HOST_MEMORY; + state.mVersionOutput = VK_MAKE_API_VERSION(7, 99, 88, 77); + ScopedGlobalDispatchState scope(state); + + const auto result = resolveVulkanGlobalDispatchGeneration(fakeGetInstanceProcAddr); + const VulkanGlobalDispatchResolutionError& error = requireError(result); + ensure("a failed version query preserves its result and ignores poisoned output", + error.mCode == VulkanGlobalDispatchResolutionCode::VersionQueryFailure && + error.mCommand == VulkanGlobalCommand::EnumerateInstanceVersion && error.mResult == VK_ERROR_OUT_OF_HOST_MEMORY && + error.mAvailableApiVersion == 0); + ensure_equals("the failed query is called exactly once", state.mVersionCalls, std::size_t{ 1 }); + ensure_equals("resolution makes no lookup after the version query", state.mLookupCount, std::size_t{ 4 }); +} + +template<> +template<> +void render_vulkan_global_dispatch_test_object::test<5>() +{ + { + GlobalDispatchState state; + state.mVersionOutput = VK_MAKE_API_VERSION(1, 1, 3, 0); + ScopedGlobalDispatchState scope(state); + + const auto result = resolveVulkanGlobalDispatchGeneration(fakeGetInstanceProcAddr); + const VulkanGlobalDispatchResolutionError& error = requireError(result); + ensure("a nonstandard API variant is rejected before its numeric version", + error.mCode == VulkanGlobalDispatchResolutionCode::UnsupportedApiVariant && + error.mCommand == VulkanGlobalCommand::EnumerateInstanceVersion && error.mResult == VK_SUCCESS && + error.mAvailableApiVersion == state.mVersionOutput); + ensure_equals("the variant query is called once", state.mVersionCalls, std::size_t{ 1 }); + ensure_equals("the variant rejection performs exactly four lookups", state.mLookupCount, std::size_t{ 4 }); + } + + { + GlobalDispatchState state; + state.mVersionOutput = VK_MAKE_API_VERSION(0, 1, 0, 999); + ScopedGlobalDispatchState scope(state); + + const auto result = resolveVulkanGlobalDispatchGeneration(fakeGetInstanceProcAddr); + const VulkanGlobalDispatchResolutionError& error = requireError(result); + ensure("a standard version below Vulkan 1.1 is rejected exactly", + error.mCode == VulkanGlobalDispatchResolutionCode::InsufficientApiVersion && + error.mCommand == VulkanGlobalCommand::EnumerateInstanceVersion && error.mResult == VK_SUCCESS && + error.mAvailableApiVersion == state.mVersionOutput); + ensure_equals("the insufficient-version query is called once", state.mVersionCalls, std::size_t{ 1 }); + ensure_equals("the insufficient-version rejection performs exactly four lookups", state.mLookupCount, std::size_t{ 4 }); + } +} + +template<> +template<> +void render_vulkan_global_dispatch_test_object::test<6>() +{ + constexpr std::array accepted_versions{ RENDERER_VULKAN_API_VERSION, VK_MAKE_API_VERSION(0, 1, 4, 37) }; + + for (const std::uint32_t accepted_version : accepted_versions) + { + GlobalDispatchState state; + state.mVersionOutput = accepted_version; + ScopedGlobalDispatchState scope(state); + + const auto result = resolveVulkanGlobalDispatchGeneration(fakeGetInstanceProcAddr); + const auto* generation = std::get_if(&result); + ensure("the exact floor and a higher standard version return a generation", generation != nullptr); + ensure("the exact resolver and required functions are retained", + generation->getInstanceProcAddr() == fakeGetInstanceProcAddr && generation->createInstance() == fakeCreateInstance && + generation->enumerateInstanceExtensionProperties() == fakeEnumerateInstanceExtensionProperties && + generation->enumerateInstanceLayerProperties() == fakeEnumerateInstanceLayerProperties && + generation->enumerateInstanceVersion() == fakeEnumerateInstanceVersion); + ensure_equals("the exact packed loader API version is retained", generation->loaderApiVersion(), accepted_version); + ensure_equals("the accepted version query is called exactly once", state.mVersionCalls, std::size_t{ 1 }); + ensure_equals("successful resolution performs exactly four lookups", state.mLookupCount, std::size_t{ 4 }); + ensureLookup(state, 0, "vkCreateInstance"); + ensureLookup(state, 1, "vkEnumerateInstanceExtensionProperties"); + ensureLookup(state, 2, "vkEnumerateInstanceLayerProperties"); + ensureLookup(state, 3, "vkEnumerateInstanceVersion"); + } +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendervulkanmaterialattachment_test.cpp b/indra/llrender/tests/llrendervulkanmaterialattachment_test.cpp new file mode 100644 index 00000000000..438d6081811 --- /dev/null +++ b/indra/llrender/tests/llrendervulkanmaterialattachment_test.cpp @@ -0,0 +1,576 @@ +/** + * @file llrendervulkanmaterialattachment_test.cpp + * @brief Tests for portable Vulkan material attachment profiles. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendervulkanmaterialattachment.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; +using namespace LLRenderVulkanMaterial; + +template +Handle fakeHandle(std::uintptr_t value) noexcept +{ + if constexpr (std::is_pointer_v) + { + return reinterpret_cast(value); + } + else + { + return static_cast(value); + } +} + +struct ImageQueryObservation +{ + VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE; + VkFormat mFormat = VK_FORMAT_UNDEFINED; + VkImageType mType = VK_IMAGE_TYPE_MAX_ENUM; + VkImageTiling mTiling = VK_IMAGE_TILING_MAX_ENUM; + VkImageUsageFlags mUsage = 0; + VkImageCreateFlags mFlags = 0; +}; + +struct FakeState +{ + static constexpr std::size_t QUERY_COUNT = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT + 1; + static constexpr std::size_t CALLBACK_COUNT = 2 + 2 * QUERY_COUNT; + + FakeState() + { + mFeatures.independentBlend = VK_TRUE; + mProperties.limits.maxColorAttachments = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + mProperties.limits.maxFragmentOutputAttachments = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + for (std::size_t index = 0; index < QUERY_COUNT; ++index) + { + mFormatOutputs[index].optimalTilingFeatures = + index < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT + ? VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT + : VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT; + mImageOutputs[index].maxExtent = { 8192, 4096, 1 }; + mImageOutputs[index].maxMipLevels = 12; + mImageOutputs[index].maxArrayLayers = 1; + mImageOutputs[index].sampleCounts = VK_SAMPLE_COUNT_1_BIT | VK_SAMPLE_COUNT_4_BIT; + mImageOutputs[index].maxResourceSize = 1ULL << 32; + } + } + + VkPhysicalDeviceFeatures mFeatures{}; + VkPhysicalDeviceProperties mProperties{}; + std::array mFormatOutputs{}; + std::array mImageResults{}; + std::array mImageOutputs{}; + std::array mFormatQueries{}; + std::array mImageQueries{}; + std::array mCallbackOrder{}; + VkPhysicalDevice mFeaturesPhysicalDevice = VK_NULL_HANDLE; + std::size_t mFeaturesCallCount = 0; + std::size_t mPropertiesCallCount = 0; + std::size_t mFormatCallCount = 0; + std::size_t mImageCallCount = 0; + std::size_t mCallbackCount = 0; + bool mOverflow = false; +}; + +void recordCallback(FakeState& state, MaterialAttachmentQuery query) noexcept +{ + if (state.mCallbackCount >= state.mCallbackOrder.size()) + { + state.mOverflow = true; + return; + } + state.mCallbackOrder[state.mCallbackCount++] = query; +} + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceFeatures(VkPhysicalDevice physical_device, VkPhysicalDeviceFeatures* features) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !features) + { + return; + } + recordCallback(*state, MaterialAttachmentQuery::PhysicalDeviceFeatures); + state->mFeaturesPhysicalDevice = physical_device; + ++state->mFeaturesCallCount; + *features = state->mFeatures; +} + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceProperties(VkPhysicalDevice physical_device, + VkPhysicalDeviceProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !properties) + { + return; + } + recordCallback(*state, MaterialAttachmentQuery::PhysicalDeviceProperties); + ++state->mPropertiesCallCount; + *properties = state->mProperties; +} + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceFormatProperties(VkPhysicalDevice physical_device, VkFormat format, + VkFormatProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !properties || state->mFormatCallCount >= FakeState::QUERY_COUNT) + { + if (state) + { + state->mOverflow = true; + } + return; + } + recordCallback(*state, MaterialAttachmentQuery::FormatProperties); + const std::size_t index = state->mFormatCallCount++; + state->mFormatQueries[index] = format; + *properties = state->mFormatOutputs[index]; +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeGetPhysicalDeviceImageFormatProperties(VkPhysicalDevice physical_device, VkFormat format, + VkImageType type, VkImageTiling tiling, VkImageUsageFlags usage, + VkImageCreateFlags flags, + VkImageFormatProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !properties || state->mImageCallCount >= FakeState::QUERY_COUNT) + { + if (state) + { + state->mOverflow = true; + } + return VK_ERROR_INITIALIZATION_FAILED; + } + recordCallback(*state, MaterialAttachmentQuery::ImageFormatProperties); + const std::size_t index = state->mImageCallCount++; + state->mImageQueries[index] = { physical_device, format, type, tiling, usage, flags }; + *properties = state->mImageOutputs[index]; + return state->mImageResults[index]; +} + +MaterialAttachmentDevice fakeDevice(FakeState& state) noexcept +{ + return { reinterpret_cast(&state), + { fakeGetPhysicalDeviceFeatures, fakeGetPhysicalDeviceProperties, fakeGetPhysicalDeviceFormatProperties, + fakeGetPhysicalDeviceImageFormatProperties } }; +} + +const MaterialAttachmentResolutionError* resolutionError(const MaterialAttachmentResolutionResult& result) noexcept +{ + return std::get_if(&result); +} + +const LegacyNormSpecAttachmentProfile* resolvedProfile(const MaterialAttachmentResolutionResult& result) noexcept +{ + return std::get_if(&result); +} + +void ensureInputError(const char* message, const MaterialAttachmentResolutionResult& result, MaterialAttachmentResolutionCode code) +{ + const auto* error = resolutionError(result); + tut::ensure(message, + error && error->mCode == code && !error->mQuery && !error->mFeature && !error->mLimit && !error->mCapability && + !error->mAttachment && !error->mColorSlot && !error->mLogicalFormat && error->mNativeFormat == VK_FORMAT_UNDEFINED && + error->mRequiredFeatures == 0 && error->mAvailableFeatures == 0 && error->mRequiredLimit == 0 && + error->mAvailableLimit == 0 && error->mRequiredCapability == 0 && error->mAvailableCapability == 0 && + error->mResult == VK_SUCCESS); +} + +std::size_t callbackCount(const FakeState& state) noexcept +{ + return state.mFeaturesCallCount + state.mPropertiesCallCount + state.mFormatCallCount + state.mImageCallCount; +} + +bool sameCapabilities(const VkImageFormatProperties& left, const VkImageFormatProperties& right) noexcept +{ + return left.maxExtent.width == right.maxExtent.width && left.maxExtent.height == right.maxExtent.height && + left.maxExtent.depth == right.maxExtent.depth && left.maxMipLevels == right.maxMipLevels && + left.maxArrayLayers == right.maxArrayLayers && left.sampleCounts == right.sampleCounts && + left.maxResourceSize == right.maxResourceSize; +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_material_attachment_test +{ +}; + +using render_vulkan_material_attachment_test_group = test_group; +using render_vulkan_material_attachment_test_object = render_vulkan_material_attachment_test_group::object; +render_vulkan_material_attachment_test_group render_vulkan_material_attachment_tests("render vulkan material attachment"); + +template<> +template<> +void render_vulkan_material_attachment_test_object::test<1>() +{ + static_assert(LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT == 4); + static_assert(std::variant_size_v == 2); + static_assert(std::is_same_v, MaterialAttachmentResolutionError>); + static_assert(std::is_same_v, LegacyNormSpecAttachmentProfile>); + static_assert(!std::is_aggregate_v); + static_assert(!std::is_default_constructible_v); + static_assert(!std::is_aggregate_v); + static_assert(!std::is_default_constructible_v); + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_nothrow_move_constructible_v); + static_assert(noexcept(resolveLegacyNormSpecAttachmentProfile(std::declval(), + std::declval()))); +} + +template<> +template<> +void render_vulkan_material_attachment_test_object::test<2>() +{ + FakeState state; + auto result = resolveLegacyNormSpecAttachmentProfile({}, legacyNormSpecModernHDRPipelineKey()); + ensureInputError("a null physical device is rejected", result, MaterialAttachmentResolutionCode::InvalidPhysicalDevice); + + MaterialAttachmentDevice device = fakeDevice(state); + device.mDispatch.mGetPhysicalDeviceFeatures = nullptr; + result = resolveLegacyNormSpecAttachmentProfile(device, legacyNormSpecModernHDRPipelineKey()); + ensureInputError("missing physical-device features is rejected", result, MaterialAttachmentResolutionCode::InvalidDispatch); + + device = fakeDevice(state); + device.mDispatch.mGetPhysicalDeviceProperties = nullptr; + result = resolveLegacyNormSpecAttachmentProfile(device, legacyNormSpecModernHDRPipelineKey()); + ensureInputError("missing physical-device properties is rejected", result, MaterialAttachmentResolutionCode::InvalidDispatch); + + device = fakeDevice(state); + device.mDispatch.mGetPhysicalDeviceFormatProperties = nullptr; + result = resolveLegacyNormSpecAttachmentProfile(device, legacyNormSpecModernHDRPipelineKey()); + ensureInputError("missing format properties is rejected", result, MaterialAttachmentResolutionCode::InvalidDispatch); + + device = fakeDevice(state); + device.mDispatch.mGetPhysicalDeviceImageFormatProperties = nullptr; + result = resolveLegacyNormSpecAttachmentProfile(device, legacyNormSpecModernHDRPipelineKey()); + ensureInputError("missing image-format properties is rejected", result, MaterialAttachmentResolutionCode::InvalidDispatch); + + LegacyNormSpecPipelineKey malformed = legacyNormSpecModernHDRPipelineKey(); + malformed.mSamples = 2; + result = resolveLegacyNormSpecAttachmentProfile(fakeDevice(state), malformed); + ensureInputError("a malformed production key is rejected", result, MaterialAttachmentResolutionCode::InvalidPipelineKey); + + result = resolveLegacyNormSpecAttachmentProfile(fakeDevice(state), legacyNormSpecDiagnosticPipelineKey()); + ensureInputError("the diagnostic profile is rejected", result, MaterialAttachmentResolutionCode::UnsupportedTargetProfile); + ensure_equals("all input rejection happens before callbacks", callbackCount(state), std::size_t{ 0 }); +} + +template<> +template<> +void render_vulkan_material_attachment_test_object::test<3>() +{ + FakeState state; + const auto key = legacyNormSpecModernHDRPipelineKey(); + const auto device = fakeDevice(state); + const auto result = resolveLegacyNormSpecAttachmentProfile(device, key); + const auto* profile = resolvedProfile(result); + ensure("the Modern HDR profile resolves", profile != nullptr); + + const std::array expected_formats{ VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R16G16B16A16_UNORM, + VK_FORMAT_R16G16B16A16_SFLOAT }; + const VkImageUsageFlags expected_usage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + const VkFormatFeatureFlags expected_features = VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT; + const VkColorComponentFlags rgba = + VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + const VkColorComponentFlags rgb = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT; + + ensure("the result retains canonical identity and physical-device provenance", + profile->targetProfile() == LegacyNormSpecTargetProfile::ModernHDR && profile->selectedFor(device.mPhysicalDevice) && + !profile->selectedFor(fakeHandle(0x5eedU))); + ensure("the supported profile retains the required logical-device feature", profile->deviceRequirements().independentBlendRequired()); + for (std::size_t slot = 0; slot < expected_formats.size(); ++slot) + { + const auto& color = profile->colors()[slot]; + ensure("every Modern HDR logical target maps exactly", + color.mLogicalFormat == key.mColorTargets[slot].mFormat && color.mNativeFormat == expected_formats[slot] && + color.mUsage == expected_usage && color.mRequiredFeatures == expected_features && + sameCapabilities(color.mCapabilities, state.mImageOutputs[slot])); + ensure("the first three targets preserve RGBA writes and zero clears", + slot == 3 || (color.mWriteMask == rgba && color.mRequiredLoadOp == VK_ATTACHMENT_LOAD_OP_CLEAR && + color.mAlphaSemantic == MaterialAttachmentAlphaSemantic::Stored && + color.mClearColor == std::array{ 0.f, 0.f, 0.f, 0.f })); + } + ensure("the widened RGB16-float target preserves implicit alpha one", + profile->colors()[3].mWriteMask == rgb && profile->colors()[3].mRequiredLoadOp == VK_ATTACHMENT_LOAD_OP_CLEAR && + profile->colors()[3].mAlphaSemantic == MaterialAttachmentAlphaSemantic::ImplicitOneAfterClear && + profile->colors()[3].mClearColor == std::array{ 0.f, 0.f, 0.f, 1.f }); + ensure("logical depth24 widens to sampled D32 float", + profile->depth().mLogicalFormat == PixelFormat::Depth24Unorm && profile->depth().mNativeFormat == VK_FORMAT_D32_SFLOAT && + profile->depth().mUsage == (VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT) && + profile->depth().mRequiredFeatures == + (VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT) && + profile->depth().mRequiredLoadOp == VK_ATTACHMENT_LOAD_OP_CLEAR && profile->depth().mClearDepth == 1.f && + profile->depth().mClearStencil == 0 && sameCapabilities(profile->depth().mCapabilities, state.mImageOutputs[4])); + + ensure_equals("physical features are queried once", state.mFeaturesCallCount, std::size_t{ 1 }); + ensure("physical features are queried from the profile's exact device", state.mFeaturesPhysicalDevice == device.mPhysicalDevice); + ensure_equals("physical limits are queried once", state.mPropertiesCallCount, std::size_t{ 1 }); + ensure_equals("all five formats are queried", state.mFormatCallCount, FakeState::QUERY_COUNT); + ensure_equals("all five exact image roles are queried", state.mImageCallCount, FakeState::QUERY_COUNT); + ensure("the fake query recorder did not overflow", !state.mOverflow); + ensure_equals("the complete query order is recorded", state.mCallbackCount, FakeState::CALLBACK_COUNT); + ensure("features precede physical properties", + state.mCallbackOrder[0] == MaterialAttachmentQuery::PhysicalDeviceFeatures && + state.mCallbackOrder[1] == MaterialAttachmentQuery::PhysicalDeviceProperties); + for (std::size_t index = 0; index < FakeState::QUERY_COUNT; ++index) + { + const auto& query = state.mImageQueries[index]; + const bool color = index < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + ensure("format and image queries retain exact order", state.mFormatQueries[index] == query.mFormat); + ensure("each ordered role queries format support before image capabilities", + state.mCallbackOrder[2 + 2 * index] == MaterialAttachmentQuery::FormatProperties && + state.mCallbackOrder[3 + 2 * index] == MaterialAttachmentQuery::ImageFormatProperties); + ensure("the queried format is the one published for that attachment", + query.mFormat == (color ? profile->colors()[index].mNativeFormat : profile->depth().mNativeFormat)); + ensure("every image query is exact 2D optimal tiling with no flags", + query.mPhysicalDevice == device.mPhysicalDevice && query.mType == VK_IMAGE_TYPE_2D && + query.mTiling == VK_IMAGE_TILING_OPTIMAL && query.mFlags == 0 && + query.mUsage == (color ? expected_usage : VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT)); + ensure("no nonportable three-channel Vulkan image format is queried", + query.mFormat != VK_FORMAT_R8G8B8_UNORM && query.mFormat != VK_FORMAT_R16G16B16_SFLOAT); + } +} + +template<> +template<> +void render_vulkan_material_attachment_test_object::test<4>() +{ + FakeState state; + const auto key = legacyNormSpecCompatibilityPipelineKey(); + const auto device = fakeDevice(state); + const auto result = resolveLegacyNormSpecAttachmentProfile(device, key); + const auto* profile = resolvedProfile(result); + ensure("the compatibility profile resolves", profile != nullptr); + + const std::array expected_formats{ VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_A2B10G10R10_UNORM_PACK32, + VK_FORMAT_R8G8B8A8_UNORM }; + ensure("the result retains compatibility identity and the required logical-device feature", + profile->targetProfile() == LegacyNormSpecTargetProfile::Compatibility && + profile->deviceRequirements().independentBlendRequired()); + for (std::size_t slot = 0; slot < expected_formats.size(); ++slot) + { + ensure("every compatibility target maps exactly", + profile->colors()[slot].mLogicalFormat == key.mColorTargets[slot].mFormat && + profile->colors()[slot].mNativeFormat == expected_formats[slot] && + state.mFormatQueries[slot] == expected_formats[slot] && state.mImageQueries[slot].mFormat == expected_formats[slot]); + } + ensure("RGB8 widens to RGBA8 with explicit RGB writes and alpha-one clear", + profile->colors()[3].mWriteMask == (VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT) && + profile->colors()[3].mRequiredLoadOp == VK_ATTACHMENT_LOAD_OP_CLEAR && + profile->colors()[3].mAlphaSemantic == MaterialAttachmentAlphaSemantic::ImplicitOneAfterClear && + profile->colors()[3].mClearColor == std::array{ 0.f, 0.f, 0.f, 1.f }); + ensure("compatibility selection never queries a three-channel Vulkan image", + state.mFormatQueries[3] != VK_FORMAT_R8G8B8_UNORM && state.mFormatQueries[3] != VK_FORMAT_R16G16B16_SFLOAT); + ensure_equals("compatibility queries physical feature support once", state.mFeaturesCallCount, std::size_t{ 1 }); + ensure("compatibility queries the exact physical device before every later callback", + state.mFeaturesPhysicalDevice == device.mPhysicalDevice && state.mCallbackCount == FakeState::CALLBACK_COUNT && + state.mCallbackOrder[0] == MaterialAttachmentQuery::PhysicalDeviceFeatures && + state.mCallbackOrder[1] == MaterialAttachmentQuery::PhysicalDeviceProperties); + ensure_equals("compatibility still proves all five roles", state.mImageCallCount, FakeState::QUERY_COUNT); +} + +template<> +template<> +void render_vulkan_material_attachment_test_object::test<5>() +{ + FakeState color_state; + color_state.mProperties.limits.maxColorAttachments = 3; + auto result = resolveLegacyNormSpecAttachmentProfile(fakeDevice(color_state), legacyNormSpecModernHDRPipelineKey()); + const auto* error = resolutionError(result); + ensure("an insufficient color-attachment limit retains exact context", + error && error->mCode == MaterialAttachmentResolutionCode::InsufficientLimit && + error->mQuery == MaterialAttachmentQuery::PhysicalDeviceProperties && + error->mLimit == MaterialAttachmentLimit::ColorAttachments && error->mRequiredLimit == 4 && error->mAvailableLimit == 3 && + !error->mAttachment); + ensure_equals("a color limit failure stops after features and physical properties", callbackCount(color_state), std::size_t{ 2 }); + + FakeState output_state; + output_state.mProperties.limits.maxFragmentOutputAttachments = 3; + result = resolveLegacyNormSpecAttachmentProfile(fakeDevice(output_state), legacyNormSpecCompatibilityPipelineKey()); + error = resolutionError(result); + ensure("an insufficient fragment-output limit retains exact context", + error && error->mCode == MaterialAttachmentResolutionCode::InsufficientLimit && + error->mQuery == MaterialAttachmentQuery::PhysicalDeviceProperties && + error->mLimit == MaterialAttachmentLimit::FragmentOutputs && error->mRequiredLimit == 4 && error->mAvailableLimit == 3 && + !error->mAttachment); + ensure_equals("an output limit failure stops after features and physical properties", callbackCount(output_state), std::size_t{ 2 }); +} + +template<> +template<> +void render_vulkan_material_attachment_test_object::test<6>() +{ + const auto key = legacyNormSpecModernHDRPipelineKey(); + for (std::size_t query = 0; query < FakeState::QUERY_COUNT; ++query) + { + const bool color = query < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + const std::array required_bits = + color ? std::array{ VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT, VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT } + : std::array{ VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT, + VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT }; + for (VkFormatFeatureFlags missing : required_bits) + { + FakeState state; + state.mFormatOutputs[query].optimalTilingFeatures &= ~missing; + state.mFormatOutputs[query].linearTilingFeatures |= missing; + state.mFormatOutputs[query].bufferFeatures |= missing; + const auto result = resolveLegacyNormSpecAttachmentProfile(fakeDevice(state), key); + const auto* error = resolutionError(result); + ensure("every missing optimal-tiling feature fails with attachment context", + error && error->mCode == MaterialAttachmentResolutionCode::MissingFormatFeatures && + error->mQuery == MaterialAttachmentQuery::FormatProperties && + error->mAttachment == (color ? MaterialAttachmentKind::Color : MaterialAttachmentKind::Depth) && + error->mColorSlot == (color ? std::optional{ static_cast(query) } : std::nullopt) && + error->mLogicalFormat == (color ? key.mColorTargets[query].mFormat : PixelFormat::Depth24Unorm) && + error->mNativeFormat == state.mFormatQueries[query] && (error->mRequiredFeatures & missing) != 0 && + (error->mAvailableFeatures & missing) == 0 && error->mResult == VK_SUCCESS); + ensure_equals("a feature failure stops at its format query", state.mFormatCallCount, query + 1); + ensure_equals("a feature failure does not issue its image query", state.mImageCallCount, query); + } + } +} + +template<> +template<> +void render_vulkan_material_attachment_test_object::test<7>() +{ + const auto key = legacyNormSpecModernHDRPipelineKey(); + for (std::size_t query = 0; query < FakeState::QUERY_COUNT; ++query) + { + FakeState state; + state.mImageResults[query] = VK_ERROR_FORMAT_NOT_SUPPORTED; + state.mImageOutputs[query].maxMipLevels = 99; + state.mImageOutputs[query].sampleCounts = VK_SAMPLE_COUNT_1_BIT; + const auto result = resolveLegacyNormSpecAttachmentProfile(fakeDevice(state), key); + const auto* error = resolutionError(result); + const bool color = query < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + ensure("every image-format failure preserves its native result and attachment context", + error && error->mCode == MaterialAttachmentResolutionCode::ImageFormatQueryFailure && + error->mQuery == MaterialAttachmentQuery::ImageFormatProperties && + error->mAttachment == (color ? MaterialAttachmentKind::Color : MaterialAttachmentKind::Depth) && + error->mColorSlot == (color ? std::optional{ static_cast(query) } : std::nullopt) && + error->mLogicalFormat == (color ? key.mColorTargets[query].mFormat : PixelFormat::Depth24Unorm) && + error->mNativeFormat == state.mImageQueries[query].mFormat && error->mResult == VK_ERROR_FORMAT_NOT_SUPPORTED && + !error->mCapability); + ensure_equals("an image-format failure stops at its format query", state.mFormatCallCount, query + 1); + ensure_equals("an image-format failure stops at its image query", state.mImageCallCount, query + 1); + ensure("a failed Modern HDR format does not trigger compatibility fallback", + state.mImageCallCount == query + 1 && !resolvedProfile(result)); + } +} + +template<> +template<> +void render_vulkan_material_attachment_test_object::test<8>() +{ + const auto key = legacyNormSpecModernHDRPipelineKey(); + const std::array expected{ + MaterialAttachmentCapability::MipLevels, MaterialAttachmentCapability::ArrayLayers, MaterialAttachmentCapability::ExtentWidth, + MaterialAttachmentCapability::ExtentHeight, MaterialAttachmentCapability::ExtentDepth, MaterialAttachmentCapability::SampleCountOne + }; + + for (std::size_t case_index = 0; case_index < expected.size(); ++case_index) + { + FakeState state; + auto& capability = state.mImageOutputs[0]; + switch (case_index) + { + case 0: + capability.maxMipLevels = 0; + break; + case 1: + capability.maxArrayLayers = 0; + break; + case 2: + capability.maxExtent.width = 0; + break; + case 3: + capability.maxExtent.height = 0; + break; + case 4: + capability.maxExtent.depth = 0; + break; + case 5: + capability.sampleCounts = VK_SAMPLE_COUNT_4_BIT; + break; + default: + break; + } + + const auto result = resolveLegacyNormSpecAttachmentProfile(fakeDevice(state), key); + const auto* error = resolutionError(result); + const std::uint64_t available = case_index == 5 ? VK_SAMPLE_COUNT_4_BIT : 0; + ensure("each insufficient image capability fails with the exact dimension", + error && error->mCode == MaterialAttachmentResolutionCode::InsufficientImageCapability && + error->mQuery == MaterialAttachmentQuery::ImageFormatProperties && error->mCapability == expected[case_index] && + error->mAttachment == MaterialAttachmentKind::Color && error->mColorSlot == 0 && error->mRequiredCapability == 1 && + error->mAvailableCapability == available && error->mResult == VK_SUCCESS); + ensure_equals("first-color capability failure stops immediately", state.mImageCallCount, std::size_t{ 1 }); + } + + FakeState depth_state; + depth_state.mImageOutputs[4].sampleCounts = VK_SAMPLE_COUNT_4_BIT; + const auto result = resolveLegacyNormSpecAttachmentProfile(fakeDevice(depth_state), key); + const auto* error = resolutionError(result); + ensure("depth capability failure retains depth context without a color slot", + error && error->mCode == MaterialAttachmentResolutionCode::InsufficientImageCapability && + error->mCapability == MaterialAttachmentCapability::SampleCountOne && error->mAttachment == MaterialAttachmentKind::Depth && + !error->mColorSlot && error->mLogicalFormat == PixelFormat::Depth24Unorm && error->mNativeFormat == VK_FORMAT_D32_SFLOAT && + error->mRequiredCapability == VK_SAMPLE_COUNT_1_BIT && error->mAvailableCapability == VK_SAMPLE_COUNT_4_BIT); + ensure_equals("depth capability failure follows four complete color queries", depth_state.mImageCallCount, FakeState::QUERY_COUNT); +} + +template<> +template<> +void render_vulkan_material_attachment_test_object::test<9>() +{ + FakeState state; + state.mFeatures.independentBlend = VK_FALSE; + + const auto result = resolveLegacyNormSpecAttachmentProfile(fakeDevice(state), legacyNormSpecModernHDRPipelineKey()); + const auto* error = resolutionError(result); + ensure("missing independent-blend support retains exact typed context", + error && error->mCode == MaterialAttachmentResolutionCode::MissingDeviceFeature && + error->mQuery == MaterialAttachmentQuery::PhysicalDeviceFeatures && + error->mFeature == MaterialAttachmentFeature::IndependentBlend && !error->mLimit && !error->mCapability && + !error->mAttachment && !error->mColorSlot && !error->mLogicalFormat && error->mNativeFormat == VK_FORMAT_UNDEFINED && + error->mRequiredFeatures == 0 && error->mAvailableFeatures == 0 && error->mRequiredLimit == 0 && + error->mAvailableLimit == 0 && error->mRequiredCapability == 0 && error->mAvailableCapability == 0 && + error->mResult == VK_SUCCESS && !resolvedProfile(result)); + ensure_equals("unsupported hardware performs exactly one feature query", state.mFeaturesCallCount, std::size_t{ 1 }); + ensure("unsupported hardware queries the supplied physical device", + state.mFeaturesPhysicalDevice == reinterpret_cast(&state)); + ensure_equals("unsupported hardware performs no properties query", state.mPropertiesCallCount, std::size_t{ 0 }); + ensure_equals("unsupported hardware performs no format query", state.mFormatCallCount, std::size_t{ 0 }); + ensure_equals("unsupported hardware performs no image-format query", state.mImageCallCount, std::size_t{ 0 }); + ensure_equals("feature rejection records one callback", state.mCallbackCount, std::size_t{ 1 }); + ensure("the sole callback has physical-feature context", + state.mCallbackOrder[0] == MaterialAttachmentQuery::PhysicalDeviceFeatures && !state.mOverflow); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendervulkanmaterialcapability_test.cpp b/indra/llrender/tests/llrendervulkanmaterialcapability_test.cpp new file mode 100644 index 00000000000..433773d78b7 --- /dev/null +++ b/indra/llrender/tests/llrendervulkanmaterialcapability_test.cpp @@ -0,0 +1,849 @@ +/** + * @file llrendervulkanmaterialcapability_test.cpp + * @brief Tests for Vulkan material pipeline physical-device capability closure. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendervulkanmaterialcapability.h" +#include "llshadermanifest.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; +using namespace LLRenderVulkanMaterial; + +constexpr std::size_t ATTACHMENT_QUERY_COUNT = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT + 1; +constexpr std::size_t MAX_QUEUE_FAMILIES = 4; +constexpr std::size_t MAX_EXTENSION_STEPS = 8; +constexpr std::size_t MAX_EXTENSIONS = 3; +constexpr std::size_t MAX_PIPELINE_CALLBACKS = 16; + +struct ExtensionStep +{ + VkResult mResult = VK_SUCCESS; + std::uint32_t mOutputCount = 0; +}; + +struct FakePhysicalDevice +{ + FakePhysicalDevice() + { + mFeatures.independentBlend = VK_TRUE; + mProperties.apiVersion = VK_API_VERSION_1_1; + mProperties.limits.maxColorAttachments = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + mProperties.limits.maxFragmentOutputAttachments = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + for (std::size_t index = 0; index < ATTACHMENT_QUERY_COUNT; ++index) + { + mFormatProperties[index].optimalTilingFeatures = + index < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT + ? VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT + : VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT; + mImageProperties[index].maxExtent = { 4096, 2048, 1 }; + mImageProperties[index].maxMipLevels = 1; + mImageProperties[index].maxArrayLayers = 1; + mImageProperties[index].sampleCounts = VK_SAMPLE_COUNT_1_BIT; + mImageProperties[index].maxResourceSize = 1ULL << 30; + } + + mQueueFirstCount = 1; + mQueueSecondCount = 1; + mQueueFamilies[0].queueFlags = VK_QUEUE_GRAPHICS_BIT; + mQueueFamilies[0].queueCount = 1; + mExtensionSteps[0].mOutputCount = 0; + mExtensionStepCount = 1; + mPortabilityStrideAlignment = 1; + } + + VkPhysicalDeviceFeatures mFeatures{}; + VkPhysicalDeviceProperties mProperties{}; + std::array mFormatProperties{}; + std::array mImageProperties{}; + std::size_t mFormatCalls = 0; + std::size_t mImageCalls = 0; + + std::uint32_t mQueueFirstCount = 0; + std::uint32_t mQueueSecondCount = 0; + std::array mQueueFamilies{}; + std::array mExtensionSteps{}; + std::size_t mExtensionStepCount = 0; + std::array mExtensions{}; + std::size_t mExtensionCount = 0; + std::uint32_t mPortabilityStrideAlignment = 1; + + std::size_t mPropertyCalls = 0; + std::size_t mQueueCalls = 0; + std::size_t mExtensionCalls = 0; + std::size_t mProperties2Calls = 0; + VkPhysicalDevice mLastPropertyDevice = VK_NULL_HANDLE; + VkPhysicalDevice mLastQueueDevice = VK_NULL_HANDLE; + VkPhysicalDevice mLastExtensionDevice = VK_NULL_HANDLE; + VkPhysicalDevice mLastProperties2Device = VK_NULL_HANDLE; + bool mExtensionLayerWasNull = true; + bool mProperties2ChainValid = true; + bool mOverflow = false; + std::array mPipelineOrder{}; + std::size_t mPipelineOrderCount = 0; +}; + +void recordPipelineCallback(FakePhysicalDevice& state, MaterialPipelineCapabilityQuery query) noexcept +{ + if (state.mPipelineOrderCount >= state.mPipelineOrder.size()) + { + state.mOverflow = true; + return; + } + state.mPipelineOrder[state.mPipelineOrderCount++] = query; +} + +void resetPipelineObservations(FakePhysicalDevice& state) noexcept +{ + state.mPropertyCalls = 0; + state.mQueueCalls = 0; + state.mExtensionCalls = 0; + state.mProperties2Calls = 0; + state.mLastPropertyDevice = VK_NULL_HANDLE; + state.mLastQueueDevice = VK_NULL_HANDLE; + state.mLastExtensionDevice = VK_NULL_HANDLE; + state.mLastProperties2Device = VK_NULL_HANDLE; + state.mExtensionLayerWasNull = true; + state.mProperties2ChainValid = true; + state.mOverflow = false; + state.mPipelineOrderCount = 0; + state.mPipelineOrder.fill(MaterialPipelineCapabilityQuery::PhysicalDeviceProperties); +} + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceFeatures(VkPhysicalDevice physical_device, VkPhysicalDeviceFeatures* features) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (state && features) + { + *features = state->mFeatures; + } +} + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceProperties(VkPhysicalDevice physical_device, + VkPhysicalDeviceProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (state && properties) + { + ++state->mPropertyCalls; + state->mLastPropertyDevice = physical_device; + recordPipelineCallback(*state, MaterialPipelineCapabilityQuery::PhysicalDeviceProperties); + *properties = state->mProperties; + } +} + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceFormatProperties(VkPhysicalDevice physical_device, VkFormat, + VkFormatProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !properties || state->mFormatCalls >= state->mFormatProperties.size()) + { + if (state) + { + state->mOverflow = true; + } + return; + } + *properties = state->mFormatProperties[state->mFormatCalls++]; +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeGetPhysicalDeviceImageFormatProperties(VkPhysicalDevice physical_device, + VkFormat, + VkImageType, + VkImageTiling, + VkImageUsageFlags, + VkImageCreateFlags, + VkImageFormatProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !properties || state->mImageCalls >= state->mImageProperties.size()) + { + if (state) + { + state->mOverflow = true; + } + return VK_ERROR_INITIALIZATION_FAILED; + } + *properties = state->mImageProperties[state->mImageCalls++]; + return VK_SUCCESS; +} + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceQueueFamilyProperties(VkPhysicalDevice physical_device, + std::uint32_t* count, + VkQueueFamilyProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !count) + { + return; + } + ++state->mQueueCalls; + state->mLastQueueDevice = physical_device; + recordPipelineCallback(*state, MaterialPipelineCapabilityQuery::QueueFamilyProperties); + if (!properties) + { + *count = state->mQueueFirstCount; + return; + } + + const std::uint32_t capacity = *count; + const std::uint32_t copied = std::min({ capacity, state->mQueueSecondCount, static_cast(state->mQueueFamilies.size()) }); + for (std::uint32_t index = 0; index < copied; ++index) + { + properties[index] = state->mQueueFamilies[index]; + } + *count = state->mQueueSecondCount; +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeEnumerateDeviceExtensionProperties(VkPhysicalDevice physical_device, + const char* layer_name, + std::uint32_t* count, + VkExtensionProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !count || state->mExtensionCalls >= state->mExtensionStepCount) + { + if (state) + { + state->mOverflow = true; + } + return VK_ERROR_INITIALIZATION_FAILED; + } + state->mLastExtensionDevice = physical_device; + state->mExtensionLayerWasNull = state->mExtensionLayerWasNull && layer_name == nullptr; + recordPipelineCallback(*state, MaterialPipelineCapabilityQuery::DeviceExtensionProperties); + + const ExtensionStep step = state->mExtensionSteps[state->mExtensionCalls++]; + const std::uint32_t capacity = properties ? *count : 0; + if (properties) + { + const std::uint32_t copied = std::min({ capacity, step.mOutputCount, static_cast(state->mExtensionCount) }); + for (std::uint32_t index = 0; index < copied; ++index) + { + properties[index] = state->mExtensions[index]; + } + } + *count = step.mOutputCount; + return step.mResult; +} + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceProperties2(VkPhysicalDevice physical_device, + VkPhysicalDeviceProperties2* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !properties) + { + return; + } + ++state->mProperties2Calls; + state->mLastProperties2Device = physical_device; + recordPipelineCallback(*state, MaterialPipelineCapabilityQuery::PhysicalDeviceProperties2); + + const bool outer_valid = properties->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2 && properties->pNext; + auto* portability = static_cast(properties->pNext); + const bool inner_valid = outer_valid && portability->sType == VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PORTABILITY_SUBSET_PROPERTIES_KHR && + portability->pNext == nullptr; + state->mProperties2ChainValid = state->mProperties2ChainValid && inner_valid; + properties->properties = state->mProperties; + if (inner_valid) + { + portability->minVertexInputBindingStrideAlignment = state->mPortabilityStrideAlignment; + } +} + +MaterialAttachmentDevice attachmentDevice(FakePhysicalDevice& state) noexcept +{ + return { reinterpret_cast(&state), + { fakeGetPhysicalDeviceFeatures, fakeGetPhysicalDeviceProperties, fakeGetPhysicalDeviceFormatProperties, + fakeGetPhysicalDeviceImageFormatProperties } }; +} + +MaterialPipelineCapabilityDevice capabilityDevice(FakePhysicalDevice& state) noexcept +{ + return { reinterpret_cast(&state), + { fakeGetPhysicalDeviceProperties, fakeGetPhysicalDeviceQueueFamilyProperties, fakeEnumerateDeviceExtensionProperties, + fakeGetPhysicalDeviceProperties2 } }; +} + +MaterialAttachmentResolutionResult resolveAttachment(FakePhysicalDevice& state, const LegacyNormSpecPipelineKey& key) noexcept +{ + return resolveLegacyNormSpecAttachmentProfile(attachmentDevice(state), key); +} + +const LegacyNormSpecAttachmentProfile* attachmentProfile(const MaterialAttachmentResolutionResult& result) noexcept +{ + return std::get_if(&result); +} + +const MaterialPipelineCapabilityResolutionError* capabilityError(const MaterialPipelineCapabilityResolutionResult& result) noexcept +{ + return std::get_if(&result); +} + +const LegacyNormSpecPipelineCapabilityProfile* capabilityProfile(const MaterialPipelineCapabilityResolutionResult& result) noexcept +{ + return std::get_if(&result); +} + +void configureExtensions(FakePhysicalDevice& state, + std::initializer_list + steps, + std::initializer_list names = {}) +{ + state.mExtensionSteps.fill({}); + state.mExtensionStepCount = steps.size(); + std::copy(steps.begin(), steps.end(), state.mExtensionSteps.begin()); + state.mExtensions.fill({}); + state.mExtensionCount = names.size(); + std::size_t index = 0; + for (const char* name : names) + { + std::strncpy(state.mExtensions[index++].extensionName, name, VK_MAX_EXTENSION_NAME_SIZE - 1); + } +} + +std::size_t pipelineCallbackCount(const FakePhysicalDevice& state) noexcept +{ + return state.mPropertyCalls + state.mQueueCalls + state.mExtensionCalls + state.mProperties2Calls; +} + +void ensureBareError(const char* message, + const MaterialPipelineCapabilityResolutionResult& result, + MaterialPipelineCapabilityResolutionCode code) +{ + const auto* error = capabilityError(result); + tut::ensure(message, + error && error->mCode == code && !error->mQuery && !error->mVertexBinding && error->mRequiredValue == 0 && + error->mAvailableValue == 0 && error->mEnumerationAttempt == 0 && error->mResult == VK_SUCCESS); +} + +bool sameImageProperties(const VkImageFormatProperties& left, const VkImageFormatProperties& right) noexcept +{ + return left.maxExtent.width == right.maxExtent.width && left.maxExtent.height == right.maxExtent.height && + left.maxExtent.depth == right.maxExtent.depth && left.maxMipLevels == right.maxMipLevels && + left.maxArrayLayers == right.maxArrayLayers && left.sampleCounts == right.sampleCounts && + left.maxResourceSize == right.maxResourceSize; +} + +bool sameAttachmentProfile(const LegacyNormSpecAttachmentProfile& left, + const LegacyNormSpecAttachmentProfile& right, + VkPhysicalDevice physical_device) noexcept +{ + if (left.targetProfile() != right.targetProfile() || !left.selectedFor(physical_device) || !right.selectedFor(physical_device) || + left.deviceRequirements() != right.deviceRequirements()) + { + return false; + } + for (std::size_t slot = 0; slot < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; ++slot) + { + const auto& l = left.colors()[slot]; + const auto& r = right.colors()[slot]; + if (l.mLogicalFormat != r.mLogicalFormat || l.mNativeFormat != r.mNativeFormat || l.mUsage != r.mUsage || + l.mRequiredFeatures != r.mRequiredFeatures || l.mWriteMask != r.mWriteMask || l.mRequiredLoadOp != r.mRequiredLoadOp || + l.mAlphaSemantic != r.mAlphaSemantic || l.mClearColor != r.mClearColor || + !sameImageProperties(l.mCapabilities, r.mCapabilities)) + { + return false; + } + } + const auto& l = left.depth(); + const auto& r = right.depth(); + return l.mLogicalFormat == r.mLogicalFormat && l.mNativeFormat == r.mNativeFormat && l.mUsage == r.mUsage && + l.mRequiredFeatures == r.mRequiredFeatures && l.mRequiredLoadOp == r.mRequiredLoadOp && l.mClearDepth == r.mClearDepth && + l.mClearStencil == r.mClearStencil && sameImageProperties(l.mCapabilities, r.mCapabilities); +} + +std::uint32_t componentCount(ShaderValueType type) noexcept +{ + switch (type) + { + case ShaderValueType::Float: + return 1; + case ShaderValueType::Float2: + return 2; + case ShaderValueType::Float3: + return 3; + case ShaderValueType::Float4: + return 4; + } + return 0; +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_material_capability_test +{ +}; + +using render_vulkan_material_capability_test_group = test_group; +using render_vulkan_material_capability_test_object = render_vulkan_material_capability_test_group::object; +render_vulkan_material_capability_test_group render_vulkan_material_capability_tests("render vulkan material capability"); + +template<> +template<> +void render_vulkan_material_capability_test_object::test<1>() +{ + static_assert(LEGACY_NORMSPEC_VERTEX_INPUT_COUNT == 7); + static_assert(std::variant_size_v == 2); + static_assert(std::is_same_v, + MaterialPipelineCapabilityResolutionError>); + static_assert( + std::is_same_v, LegacyNormSpecPipelineCapabilityProfile>); + static_assert(!std::is_aggregate_v); + static_assert(!std::is_default_constructible_v); + static_assert(!std::is_aggregate_v); + static_assert(!std::is_default_constructible_v); + static_assert(!std::is_constructible_v); + static_assert(std::is_trivially_copyable_v); + static_assert(std::is_nothrow_move_constructible_v); + static_assert(std::is_same_v().attachmentProfile()), + const LegacyNormSpecAttachmentProfile&>); + static_assert(std::is_same_v().vertexInputs()), + const std::array&>); + static_assert(noexcept(resolveLegacyNormSpecPipelineCapabilityProfile(std::declval(), + std::declval()))); +} + +template<> +template<> +void render_vulkan_material_capability_test_object::test<2>() +{ + FakePhysicalDevice profile_state; + auto attachment_result = resolveAttachment(profile_state, legacyNormSpecModernHDRPipelineKey()); + const auto* attachment = attachmentProfile(attachment_result); + ensure("the preflight fixture resolves a real attachment profile", attachment != nullptr); + resetPipelineObservations(profile_state); + + auto device = capabilityDevice(profile_state); + device.mPhysicalDevice = VK_NULL_HANDLE; + auto result = resolveLegacyNormSpecPipelineCapabilityProfile(device, *attachment); + ensureBareError("a null physical device is rejected", result, MaterialPipelineCapabilityResolutionCode::InvalidPhysicalDevice); + + device = capabilityDevice(profile_state); + device.mDispatch.mGetPhysicalDeviceProperties = nullptr; + result = resolveLegacyNormSpecPipelineCapabilityProfile(device, *attachment); + ensureBareError("missing physical-device properties is rejected", result, MaterialPipelineCapabilityResolutionCode::InvalidDispatch); + + device = capabilityDevice(profile_state); + device.mDispatch.mGetPhysicalDeviceQueueFamilyProperties = nullptr; + result = resolveLegacyNormSpecPipelineCapabilityProfile(device, *attachment); + ensureBareError("missing queue-family properties is rejected", result, MaterialPipelineCapabilityResolutionCode::InvalidDispatch); + + device = capabilityDevice(profile_state); + device.mDispatch.mEnumerateDeviceExtensionProperties = nullptr; + result = resolveLegacyNormSpecPipelineCapabilityProfile(device, *attachment); + ensureBareError("missing device-extension enumeration is rejected", result, MaterialPipelineCapabilityResolutionCode::InvalidDispatch); + + device = capabilityDevice(profile_state); + device.mDispatch.mGetPhysicalDeviceProperties2 = nullptr; + result = resolveLegacyNormSpecPipelineCapabilityProfile(device, *attachment); + ensureBareError("missing physical-device properties2 is rejected", result, MaterialPipelineCapabilityResolutionCode::InvalidDispatch); + ensure_equals("all null and dispatch rejection precedes callbacks", pipelineCallbackCount(profile_state), std::size_t{ 0 }); + + FakePhysicalDevice other_state; + result = resolveLegacyNormSpecPipelineCapabilityProfile(capabilityDevice(other_state), *attachment); + ensureBareError("an attachment profile from another physical device is rejected", result, + MaterialPipelineCapabilityResolutionCode::AttachmentProfilePhysicalDeviceMismatch); + ensure_equals("provenance mismatch precedes callbacks", pipelineCallbackCount(other_state), std::size_t{ 0 }); +} + +template<> +template<> +void render_vulkan_material_capability_test_object::test<3>() +{ + FakePhysicalDevice state; + state.mProperties.apiVersion = VK_API_VERSION_1_0; + auto attachment_result = resolveAttachment(state, legacyNormSpecCompatibilityPipelineKey()); + const auto* attachment = attachmentProfile(attachment_result); + ensure("the API-floor fixture resolves its attachment profile", attachment != nullptr); + resetPipelineObservations(state); + + const auto result = resolveLegacyNormSpecPipelineCapabilityProfile(capabilityDevice(state), *attachment); + const auto* error = capabilityError(result); + ensure("Vulkan 1.0 is rejected with the exact API floor", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::InsufficientApiVersion && + error->mQuery == MaterialPipelineCapabilityQuery::PhysicalDeviceProperties && error->mRequiredValue == VK_API_VERSION_1_1 && + error->mAvailableValue == VK_API_VERSION_1_0 && !error->mVertexBinding && error->mResult == VK_SUCCESS); + ensure_equals("API rejection performs only the properties query", pipelineCallbackCount(state), std::size_t{ 1 }); + + FakePhysicalDevice variant_state; + variant_state.mProperties.apiVersion = VK_MAKE_API_VERSION(1, 1, 1, 0); + attachment_result = resolveAttachment(variant_state, legacyNormSpecModernHDRPipelineKey()); + attachment = attachmentProfile(attachment_result); + ensure("the API-variant fixture resolves its attachment profile", attachment != nullptr); + resetPipelineObservations(variant_state); + const auto variant_result = resolveLegacyNormSpecPipelineCapabilityProfile(capabilityDevice(variant_state), *attachment); + error = capabilityError(variant_result); + ensure("a nonzero API variant is rejected before numeric version comparison", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::UnsupportedApiVariant && + error->mQuery == MaterialPipelineCapabilityQuery::PhysicalDeviceProperties && error->mRequiredValue == 0 && + error->mAvailableValue == 1 && !error->mVertexBinding && error->mResult == VK_SUCCESS); + ensure_equals("API-variant rejection performs only the properties query", pipelineCallbackCount(variant_state), std::size_t{ 1 }); +} + +template<> +template<> +void render_vulkan_material_capability_test_object::test<4>() +{ + const std::array keys{ legacyNormSpecModernHDRPipelineKey(), legacyNormSpecCompatibilityPipelineKey() }; + const std::array expected_inputs{ + MaterialVertexInputCapability{ VertexSemantic::Position, VertexFormat::Float3, 0, 0, 0, 16, VK_FORMAT_R32G32B32_SFLOAT }, + MaterialVertexInputCapability{ VertexSemantic::Normal, VertexFormat::Float3, 1, 1, 0, 16, VK_FORMAT_R32G32B32_SFLOAT }, + MaterialVertexInputCapability{ VertexSemantic::TexCoord0, VertexFormat::Float2, 2, 2, 0, 8, VK_FORMAT_R32G32_SFLOAT }, + MaterialVertexInputCapability{ VertexSemantic::Color, VertexFormat::UNorm8x4, 3, 3, 0, 4, VK_FORMAT_R8G8B8A8_UNORM }, + MaterialVertexInputCapability{ VertexSemantic::Tangent, VertexFormat::Float4, 4, 4, 0, 16, VK_FORMAT_R32G32B32A32_SFLOAT }, + MaterialVertexInputCapability{ VertexSemantic::TexCoord1, VertexFormat::Float2, 5, 5, 0, 8, VK_FORMAT_R32G32_SFLOAT }, + MaterialVertexInputCapability{ VertexSemantic::TexCoord2, VertexFormat::Float2, 6, 6, 0, 8, VK_FORMAT_R32G32_SFLOAT } + }; + constexpr std::uint32_t accepted_api = VK_MAKE_API_VERSION(0, 1, 2, 203); + + for (const auto& key : keys) + { + FakePhysicalDevice state; + state.mProperties.apiVersion = accepted_api; + auto attachment_result = resolveAttachment(state, key); + const auto* attachment = attachmentProfile(attachment_result); + ensure("both production attachment profiles resolve", attachment != nullptr); + resetPipelineObservations(state); + + const auto result = resolveLegacyNormSpecPipelineCapabilityProfile(capabilityDevice(state), *attachment); + const auto* profile = capabilityProfile(result); + ensure("both production pipeline capability profiles resolve", profile != nullptr); + ensure("the successful profile preserves physical-device and target provenance", + profile->selectedFor(reinterpret_cast(&state)) && + !profile->selectedFor(reinterpret_cast(std::uintptr_t{ 0x5eed })) && + profile->targetProfile() == key.mTargetProfile); + ensure("the exact attachment profile is retained immutably", + &profile->attachmentProfile() != attachment && + sameAttachmentProfile(profile->attachmentProfile(), *attachment, reinterpret_cast(&state))); + ensure("the exact API and neutral portability state are retained", + profile->apiVersion() == accepted_api && !profile->portabilitySubsetAdvertised() && + profile->minVertexInputBindingStrideAlignment() == 1); + const auto requirements = profile->logicalDeviceRequirements(); + ensure("all successful logical-device obligations are explicit", + requirements.independentBlendRequired() && requirements.graphicsQueueRequired() && + !requirements.portabilitySubsetExtensionRequired()); + ensure("all canonical vertex records are retained exactly", profile->vertexInputs() == expected_inputs); + + const auto manifest = legacyNormSpecShaderManifest(key, ShaderBackend::Vulkan); + ensure("the successful profile has the exact production shader manifest", + manifest && validLegacyNormSpecProductionShaderManifest(*manifest) && + manifest->mVertexInputs.size() == profile->vertexInputs().size()); + for (std::size_t index = 0; index < profile->vertexInputs().size(); ++index) + { + const auto& input = profile->vertexInputs()[index]; + const auto& declared = manifest->mVertexInputs[index]; + ensure("each retained vertex record agrees with the shader manifest", + input.mSemantic == declared.mSemantic && input.mLogicalFormat == declared.mFormat && + input.mLocation == declared.mLocation && input.mBinding == declared.mBinding && input.mOffset == 0 && + input.mStride == declared.mStride); + } + + std::uint32_t interstage_components = 0; + for (const auto& variable : manifest->mInterstageVariables) + { + interstage_components += componentCount(variable.mType); + } + std::uint32_t descriptor_sets = 0; + for (const auto& image : manifest->mSampledImages) + { + descriptor_sets = std::max(descriptor_sets, image.mSet + 1); + } + if (manifest->mParameterBlock) + { + descriptor_sets = std::max(descriptor_sets, manifest->mParameterBlock->mSet + 1); + } + ensure("the fixed contract fits Vulkan 1.1 core vertex and interstage floors", + manifest->mVertexInputs.size() <= 16 && interstage_components == 20 && interstage_components <= 64 && + expected_inputs.back().mBinding < 16 && expected_inputs[0].mStride <= 2048); + ensure("the fixed contract fits Vulkan 1.1 descriptor and uniform floors", + descriptor_sets == 2 && descriptor_sets <= 4 && manifest->mSampledImages.size() == 3 && + manifest->mSampledImages.size() <= 16 && manifest->mParameterBlock && manifest->mParameterBlock->mByteSize == 272 && + manifest->mParameterBlock->mByteSize <= 16384); + ensure("the fixed contract fits Vulkan 1.1 output and fixed-function floors", + manifest->mLogicalFragmentOutputs.size() == 4 && manifest->mLogicalFragmentOutputs.size() <= 4 && + manifest->mPushConstantRanges.empty()); + + ensure("the successful query transaction uses the exact physical device and null extension layer", + state.mLastPropertyDevice == reinterpret_cast(&state) && + state.mLastQueueDevice == reinterpret_cast(&state) && + state.mLastExtensionDevice == reinterpret_cast(&state) && state.mExtensionLayerWasNull && + state.mProperties2Calls == 0 && !state.mOverflow); + ensure("the non-portability query order is fixed", + state.mPipelineOrderCount == 4 && state.mPipelineOrder[0] == MaterialPipelineCapabilityQuery::PhysicalDeviceProperties && + state.mPipelineOrder[1] == MaterialPipelineCapabilityQuery::QueueFamilyProperties && + state.mPipelineOrder[2] == MaterialPipelineCapabilityQuery::QueueFamilyProperties && + state.mPipelineOrder[3] == MaterialPipelineCapabilityQuery::DeviceExtensionProperties); + } +} + +template<> +template<> +void render_vulkan_material_capability_test_object::test<5>() +{ + auto resolve_queue_case = [](FakePhysicalDevice& state) + { + auto attachment_result = resolveAttachment(state, legacyNormSpecModernHDRPipelineKey()); + const auto* attachment = attachmentProfile(attachment_result); + tut::ensure("the queue fixture resolves an attachment profile", attachment != nullptr); + resetPipelineObservations(state); + return resolveLegacyNormSpecPipelineCapabilityProfile(capabilityDevice(state), *attachment); + }; + + FakePhysicalDevice empty; + empty.mQueueFirstCount = 0; + auto result = resolve_queue_case(empty); + const auto* error = capabilityError(result); + ensure("zero queue families fail closed", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::MissingGraphicsQueueFamily && + error->mQuery == MaterialPipelineCapabilityQuery::QueueFamilyProperties); + ensure_equals("zero queue families need only the count call", empty.mQueueCalls, std::size_t{ 1 }); + + FakePhysicalDevice compute; + compute.mQueueFamilies[0].queueFlags = VK_QUEUE_COMPUTE_BIT; + result = resolve_queue_case(compute); + error = capabilityError(result); + ensure("a non-empty compute-only family is insufficient", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::MissingGraphicsQueueFamily && + error->mQuery == MaterialPipelineCapabilityQuery::QueueFamilyProperties); + + FakePhysicalDevice zero_count; + zero_count.mQueueFamilies[0].queueFlags = VK_QUEUE_GRAPHICS_BIT; + zero_count.mQueueFamilies[0].queueCount = 0; + result = resolve_queue_case(zero_count); + error = capabilityError(result); + ensure("a graphics flag with no queues is insufficient", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::MissingGraphicsQueueFamily && + error->mQuery == MaterialPipelineCapabilityQuery::QueueFamilyProperties); + + FakePhysicalDevice later_valid; + later_valid.mQueueFirstCount = 3; + later_valid.mQueueSecondCount = 3; + later_valid.mQueueFamilies[0].queueFlags = VK_QUEUE_COMPUTE_BIT; + later_valid.mQueueFamilies[0].queueCount = 1; + later_valid.mQueueFamilies[1].queueFlags = VK_QUEUE_GRAPHICS_BIT; + later_valid.mQueueFamilies[1].queueCount = 0; + later_valid.mQueueFamilies[2].queueFlags = VK_QUEUE_GRAPHICS_BIT | VK_QUEUE_TRANSFER_BIT; + later_valid.mQueueFamilies[2].queueCount = 2; + result = resolve_queue_case(later_valid); + ensure("a later non-empty graphics family is accepted", capabilityProfile(result) != nullptr); + + FakePhysicalDevice invalid_output; + invalid_output.mQueueFirstCount = 1; + invalid_output.mQueueSecondCount = 2; + result = resolve_queue_case(invalid_output); + error = capabilityError(result); + ensure("a second queue count above capacity is rejected with exact bounds", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::InvalidEnumerationOutput && + error->mQuery == MaterialPipelineCapabilityQuery::QueueFamilyProperties && error->mRequiredValue == 1 && + error->mAvailableValue == 2); +} + +template<> +template<> +void render_vulkan_material_capability_test_object::test<6>() +{ + auto resolve_extension_case = [](FakePhysicalDevice& state) + { + auto attachment_result = resolveAttachment(state, legacyNormSpecCompatibilityPipelineKey()); + const auto* attachment = attachmentProfile(attachment_result); + tut::ensure("the extension fixture resolves an attachment profile", attachment != nullptr); + resetPipelineObservations(state); + return resolveLegacyNormSpecPipelineCapabilityProfile(capabilityDevice(state), *attachment); + }; + + FakePhysicalDevice count_failure; + configureExtensions(count_failure, { { VK_ERROR_INITIALIZATION_FAILED, 0 } }); + auto result = resolve_extension_case(count_failure); + const auto* error = capabilityError(result); + ensure("a count query failure preserves result and attempt", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::EnumerationFailure && + error->mQuery == MaterialPipelineCapabilityQuery::DeviceExtensionProperties && + error->mResult == VK_ERROR_INITIALIZATION_FAILED && error->mEnumerationAttempt == 1); + + FakePhysicalDevice list_failure; + configureExtensions(list_failure, { { VK_SUCCESS, 1 }, { VK_ERROR_OUT_OF_HOST_MEMORY, 1 } }, { "VK_EXT_fake" }); + result = resolve_extension_case(list_failure); + error = capabilityError(result); + ensure("a list query failure preserves result and attempt", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::EnumerationFailure && + error->mQuery == MaterialPipelineCapabilityQuery::DeviceExtensionProperties && + error->mResult == VK_ERROR_OUT_OF_HOST_MEMORY && error->mEnumerationAttempt == 1); + + FakePhysicalDevice failed_list_output; + configureExtensions(failed_list_output, { { VK_SUCCESS, 1 }, { VK_ERROR_INITIALIZATION_FAILED, 2 } }, { "VK_EXT_fake" }); + result = resolve_extension_case(failed_list_output); + error = capabilityError(result); + ensure("a failed list does not interpret its undefined returned count", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::EnumerationFailure && + error->mQuery == MaterialPipelineCapabilityQuery::DeviceExtensionProperties && + error->mResult == VK_ERROR_INITIALIZATION_FAILED && error->mEnumerationAttempt == 1 && error->mRequiredValue == 0 && + error->mAvailableValue == 0); + + FakePhysicalDevice invalid_output; + configureExtensions(invalid_output, { { VK_SUCCESS, 1 }, { VK_SUCCESS, 2 } }, { "VK_EXT_fake" }); + result = resolve_extension_case(invalid_output); + error = capabilityError(result); + ensure("an extension list count above capacity is rejected with exact bounds", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::InvalidEnumerationOutput && + error->mQuery == MaterialPipelineCapabilityQuery::DeviceExtensionProperties && error->mRequiredValue == 1 && + error->mAvailableValue == 2 && error->mEnumerationAttempt == 1 && error->mResult == VK_SUCCESS); +} + +template<> +template<> +void render_vulkan_material_capability_test_object::test<7>() +{ + auto resolve_extension_case = [](FakePhysicalDevice& state) + { + auto attachment_result = resolveAttachment(state, legacyNormSpecModernHDRPipelineKey()); + const auto* attachment = attachmentProfile(attachment_result); + tut::ensure("the retry fixture resolves an attachment profile", attachment != nullptr); + resetPipelineObservations(state); + return resolveLegacyNormSpecPipelineCapabilityProfile(capabilityDevice(state), *attachment); + }; + + FakePhysicalDevice count_retry; + configureExtensions(count_retry, { { VK_INCOMPLETE, 0 }, { VK_SUCCESS, 0 } }); + auto result = resolve_extension_case(count_retry); + ensure("an incomplete count transaction retries and succeeds", capabilityProfile(result) != nullptr); + ensure_equals("count retry consumed exactly two attempts", count_retry.mExtensionCalls, std::size_t{ 2 }); + + FakePhysicalDevice list_retry; + configureExtensions(list_retry, { { VK_SUCCESS, 1 }, { VK_INCOMPLETE, 1 }, { VK_SUCCESS, 1 }, { VK_SUCCESS, 1 } }, { "VK_EXT_fake" }); + result = resolve_extension_case(list_retry); + ensure("an incomplete list retries the complete transaction", capabilityProfile(result) != nullptr); + ensure_equals("list retry consumed two complete transactions", list_retry.mExtensionCalls, std::size_t{ 4 }); + + FakePhysicalDevice incomplete_oversized; + configureExtensions(incomplete_oversized, { { VK_SUCCESS, 1 }, { VK_INCOMPLETE, 2 } }, { "VK_EXT_fake" }); + result = resolve_extension_case(incomplete_oversized); + const auto* error = capabilityError(result); + ensure("an incomplete list still rejects a returned count above capacity", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::InvalidEnumerationOutput && + error->mQuery == MaterialPipelineCapabilityQuery::DeviceExtensionProperties && error->mResult == VK_INCOMPLETE && + error->mRequiredValue == 1 && error->mAvailableValue == 2 && error->mEnumerationAttempt == 1); + + FakePhysicalDevice exhausted; + configureExtensions(exhausted, { { VK_INCOMPLETE, 0 }, { VK_INCOMPLETE, 0 }, { VK_INCOMPLETE, 0 } }); + result = resolve_extension_case(exhausted); + error = capabilityError(result); + ensure("three incomplete attempts fail closed with exact retry context", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::EnumerationIncomplete && + error->mQuery == MaterialPipelineCapabilityQuery::DeviceExtensionProperties && error->mResult == VK_INCOMPLETE && + error->mEnumerationAttempt == 3); + ensure_equals("the retry bound is exact", exhausted.mExtensionCalls, std::size_t{ 3 }); +} + +template<> +template<> +void render_vulkan_material_capability_test_object::test<8>() +{ + auto resolve_named_case = [](FakePhysicalDevice& state) + { + auto attachment_result = resolveAttachment(state, legacyNormSpecCompatibilityPipelineKey()); + const auto* attachment = attachmentProfile(attachment_result); + tut::ensure("the extension-name fixture resolves an attachment profile", attachment != nullptr); + resetPipelineObservations(state); + return resolveLegacyNormSpecPipelineCapabilityProfile(capabilityDevice(state), *attachment); + }; + + FakePhysicalDevice decoy; + configureExtensions(decoy, { { VK_SUCCESS, 1 }, { VK_SUCCESS, 1 } }, { "VK_KHR_portability_subset.extra" }); + auto result = resolve_named_case(decoy); + const auto* profile = capabilityProfile(result); + ensure("a prefix extension name is not mistaken for portability subset", + profile && !profile->portabilitySubsetAdvertised() && + !profile->logicalDeviceRequirements().portabilitySubsetExtensionRequired() && + profile->minVertexInputBindingStrideAlignment() == 1 && decoy.mProperties2Calls == 0); + + FakePhysicalDevice exact; + exact.mPortabilityStrideAlignment = 4; + configureExtensions(exact, { { VK_SUCCESS, 1 }, { VK_SUCCESS, 1 } }, { "VK_KHR_portability_subset" }); + result = resolve_named_case(exact); + profile = capabilityProfile(result); + ensure("the exact extension name publishes the logical-device obligation", + profile && profile->portabilitySubsetAdvertised() && profile->logicalDeviceRequirements().portabilitySubsetExtensionRequired() && + profile->minVertexInputBindingStrideAlignment() == 4); + ensure("portability properties use the exact properties2 chain and physical device", + exact.mProperties2Calls == 1 && exact.mProperties2ChainValid && + exact.mLastProperties2Device == reinterpret_cast(&exact)); + ensure("portability properties are queried last", + exact.mPipelineOrderCount == 6 && exact.mPipelineOrder[5] == MaterialPipelineCapabilityQuery::PhysicalDeviceProperties2 && + !exact.mOverflow); +} + +template<> +template<> +void render_vulkan_material_capability_test_object::test<9>() +{ + auto resolve_alignment = [](FakePhysicalDevice& state) + { + configureExtensions(state, { { VK_SUCCESS, 1 }, { VK_SUCCESS, 1 } }, { "VK_KHR_portability_subset" }); + auto attachment_result = resolveAttachment(state, legacyNormSpecModernHDRPipelineKey()); + const auto* attachment = attachmentProfile(attachment_result); + tut::ensure("the alignment fixture resolves an attachment profile", attachment != nullptr); + resetPipelineObservations(state); + return resolveLegacyNormSpecPipelineCapabilityProfile(capabilityDevice(state), *attachment); + }; + + for (const std::uint32_t alignment : std::array{ 1, 2, 4 }) + { + FakePhysicalDevice state; + state.mPortabilityStrideAlignment = alignment; + const auto result = resolve_alignment(state); + const auto* profile = capabilityProfile(result); + ensure("alignments one, two, and four accept every canonical stride", + profile && profile->minVertexInputBindingStrideAlignment() == alignment && profile->portabilitySubsetAdvertised() && + state.mProperties2ChainValid); + } + + for (const std::uint32_t alignment : std::array{ 0, 3 }) + { + FakePhysicalDevice state; + state.mPortabilityStrideAlignment = alignment; + const auto result = resolve_alignment(state); + const auto* error = capabilityError(result); + ensure("zero and non-power-of-two alignments are rejected", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::InvalidPortabilityAlignment && + error->mQuery == MaterialPipelineCapabilityQuery::PhysicalDeviceProperties2 && !error->mVertexBinding && + error->mRequiredValue == 1 && error->mAvailableValue == alignment); + } + + FakePhysicalDevice too_wide; + too_wide.mPortabilityStrideAlignment = 8; + const auto result = resolve_alignment(too_wide); + const auto* error = capabilityError(result); + ensure("alignment eight identifies the first incompatible four-byte color binding", + error && error->mCode == MaterialPipelineCapabilityResolutionCode::IncompatiblePortabilityStride && + error->mQuery == MaterialPipelineCapabilityQuery::PhysicalDeviceProperties2 && error->mVertexBinding == 3 && + error->mRequiredValue == 8 && error->mAvailableValue == 4); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendervulkanmaterialdescriptor_test.cpp b/indra/llrender/tests/llrendervulkanmaterialdescriptor_test.cpp new file mode 100644 index 00000000000..7906145ed34 --- /dev/null +++ b/indra/llrender/tests/llrendervulkanmaterialdescriptor_test.cpp @@ -0,0 +1,694 @@ +/** + * @file llrendervulkanmaterialdescriptor_test.cpp + * @brief Tests for immutable populated Vulkan material descriptor generations. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llmaterialcontract.h" +#include "llrendervulkanmaterialdescriptor.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderVulkanMaterial; + +inline constexpr VkDeviceSize MATERIAL_PARAMETER_SIZE = static_cast(sizeof(LLRenderContract::MaterialParameters)); + +template +Handle fakeHandle(std::uintptr_t value) noexcept +{ + if constexpr (std::is_pointer_v) + { + return reinterpret_cast(value); + } + else + { + return static_cast(value); + } +} + +struct PoolCreateObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkStructureType mType = VK_STRUCTURE_TYPE_MAX_ENUM; + const void* mNext = nullptr; + VkDescriptorPoolCreateFlags mFlags = 0; + std::uint32_t mMaxSets = 0; + std::uint32_t mSizeCount = 0; + std::array mSizes{}; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct SetAllocateObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkStructureType mType = VK_STRUCTURE_TYPE_MAX_ENUM; + const void* mNext = nullptr; + VkDescriptorPool mPool = VK_NULL_HANDLE; + std::uint32_t mSetCount = 0; + std::array mLayouts{}; +}; + +struct WriteObservation +{ + VkStructureType mType = VK_STRUCTURE_TYPE_MAX_ENUM; + const void* mNext = nullptr; + VkDescriptorSet mSet = VK_NULL_HANDLE; + std::uint32_t mBinding = 0; + std::uint32_t mArrayElement = 0; + std::uint32_t mDescriptorCount = 0; + VkDescriptorType mDescriptorType = VK_DESCRIPTOR_TYPE_MAX_ENUM; + bool mHasImageInfo = false; + bool mHasBufferInfo = false; + bool mHasTexelView = false; + VkDescriptorImageInfo mImage{}; + VkDescriptorBufferInfo mBuffer{}; +}; + +struct UpdateObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + std::uint32_t mWriteCount = 0; + std::uint32_t mCopyCount = 0; + const VkCopyDescriptorSet* mCopies = nullptr; + std::array mWrites{}; +}; + +struct PoolDestroyObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkDescriptorPool mPool = VK_NULL_HANDLE; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct FakeState +{ + static constexpr std::size_t MAX_CALLS = 8; + static constexpr std::size_t MAX_SETS = 16; + + FakeState() + { + for (std::size_t call = 0; call < MAX_CALLS; ++call) + { + mPoolOutputs[call] = fakeHandle(0x3001U + call); + for (std::size_t set = 0; set < MAX_SETS; ++set) + { + mSetOutputs[call][set] = fakeHandle(0x4001U + call * MAX_SETS + set); + } + } + } + + std::array mPoolResults{}; + std::array mPoolOutputs{}; + std::array mPoolCreates{}; + std::array mAllocateResults{}; + std::array, MAX_CALLS> mSetOutputs{}; + std::array mAllocates{}; + std::array mUpdates{}; + std::array mPoolDestroys{}; + std::size_t mPoolCreateCount = 0; + std::size_t mAllocateCount = 0; + std::size_t mUpdateCount = 0; + std::size_t mPoolDestroyCount = 0; + std::size_t mLayoutCreateCount = 0; + std::size_t mLayoutDestroyCount = 0; + std::size_t mPipelineCreateCount = 0; + std::size_t mPipelineDestroyCount = 0; + bool mObservationOverflow = false; +}; + +VKAPI_ATTR VkResult VKAPI_CALL fakeCreateDescriptorSetLayout(VkDevice device, const VkDescriptorSetLayoutCreateInfo*, + const VkAllocationCallbacks*, VkDescriptorSetLayout* output) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || !output) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + *output = fakeHandle(0x1001U + state->mLayoutCreateCount++); + return VK_SUCCESS; +} + +VKAPI_ATTR void VKAPI_CALL fakeDestroyDescriptorSetLayout(VkDevice device, VkDescriptorSetLayout, const VkAllocationCallbacks*) noexcept +{ + if (auto* state = reinterpret_cast(device)) + { + ++state->mLayoutDestroyCount; + } +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeCreatePipelineLayout(VkDevice device, const VkPipelineLayoutCreateInfo*, const VkAllocationCallbacks*, + VkPipelineLayout* output) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || !output) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + *output = fakeHandle(0x2001U + state->mPipelineCreateCount++); + return VK_SUCCESS; +} + +VKAPI_ATTR void VKAPI_CALL fakeDestroyPipelineLayout(VkDevice device, VkPipelineLayout, const VkAllocationCallbacks*) noexcept +{ + if (auto* state = reinterpret_cast(device)) + { + ++state->mPipelineDestroyCount; + } +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeCreateDescriptorPool(VkDevice device, const VkDescriptorPoolCreateInfo* info, + const VkAllocationCallbacks* allocator, VkDescriptorPool* output) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || !info || !output || state->mPoolCreateCount >= FakeState::MAX_CALLS || info->poolSizeCount > 2 || + (info->poolSizeCount != 0 && !info->pPoolSizes)) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + + const std::size_t index = state->mPoolCreateCount++; + auto& observation = state->mPoolCreates[index]; + observation.mDevice = device; + observation.mType = info->sType; + observation.mNext = info->pNext; + observation.mFlags = info->flags; + observation.mMaxSets = info->maxSets; + observation.mSizeCount = info->poolSizeCount; + observation.mAllocator = allocator; + for (std::uint32_t pool_size = 0; pool_size < info->poolSizeCount; ++pool_size) + { + observation.mSizes[pool_size] = info->pPoolSizes[pool_size]; + } + + *output = state->mPoolOutputs[index]; + return state->mPoolResults[index]; +} + +VKAPI_ATTR void VKAPI_CALL fakeDestroyDescriptorPool(VkDevice device, VkDescriptorPool pool, + const VkAllocationCallbacks* allocator) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || state->mPoolDestroyCount >= FakeState::MAX_CALLS) + { + return; + } + state->mPoolDestroys[state->mPoolDestroyCount++] = { device, pool, allocator }; +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeAllocateDescriptorSets(VkDevice device, const VkDescriptorSetAllocateInfo* info, + VkDescriptorSet* output) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || !info || !output || state->mAllocateCount >= FakeState::MAX_CALLS || info->descriptorSetCount > FakeState::MAX_SETS || + (info->descriptorSetCount != 0 && !info->pSetLayouts)) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + + const std::size_t index = state->mAllocateCount++; + auto& observation = state->mAllocates[index]; + observation.mDevice = device; + observation.mType = info->sType; + observation.mNext = info->pNext; + observation.mPool = info->descriptorPool; + observation.mSetCount = info->descriptorSetCount; + for (std::uint32_t set = 0; set < info->descriptorSetCount; ++set) + { + observation.mLayouts[set] = info->pSetLayouts[set]; + output[set] = state->mSetOutputs[index][set]; + } + return state->mAllocateResults[index]; +} + +VKAPI_ATTR void VKAPI_CALL fakeUpdateDescriptorSets(VkDevice device, std::uint32_t write_count, const VkWriteDescriptorSet* writes, + std::uint32_t copy_count, const VkCopyDescriptorSet* copies) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || state->mUpdateCount >= FakeState::MAX_CALLS || write_count > 32 || (write_count != 0 && !writes)) + { + if (state) + { + state->mObservationOverflow = true; + } + return; + } + + auto& observation = state->mUpdates[state->mUpdateCount++]; + observation.mDevice = device; + observation.mWriteCount = write_count; + observation.mCopyCount = copy_count; + observation.mCopies = copies; + for (std::uint32_t index = 0; index < write_count; ++index) + { + const VkWriteDescriptorSet& write = writes[index]; + WriteObservation& captured = observation.mWrites[index]; + captured.mType = write.sType; + captured.mNext = write.pNext; + captured.mSet = write.dstSet; + captured.mBinding = write.dstBinding; + captured.mArrayElement = write.dstArrayElement; + captured.mDescriptorCount = write.descriptorCount; + captured.mDescriptorType = write.descriptorType; + captured.mHasImageInfo = write.pImageInfo != nullptr; + captured.mHasBufferInfo = write.pBufferInfo != nullptr; + captured.mHasTexelView = write.pTexelBufferView != nullptr; + if (write.pImageInfo) + { + captured.mImage = *write.pImageInfo; + } + if (write.pBufferInfo) + { + captured.mBuffer = *write.pBufferInfo; + } + } +} + +MaterialLayoutDevice fakeLayoutDevice(FakeState& state) noexcept +{ + return { reinterpret_cast(&state), + { fakeCreateDescriptorSetLayout, fakeDestroyDescriptorSetLayout, fakeCreatePipelineLayout, fakeDestroyPipelineLayout } }; +} + +MaterialDescriptorDevice fakeDescriptorDevice(FakeState& state) noexcept +{ + return { reinterpret_cast(&state), + { fakeCreateDescriptorPool, fakeDestroyDescriptorPool, fakeAllocateDescriptorSets, fakeUpdateDescriptorSets } }; +} + +std::unique_ptr makeLayout(FakeState& state) +{ + auto result = createLegacyNormSpecPipelineLayout(fakeLayoutDevice(state)); + auto* owner = std::get_if>(&result); + return owner ? std::move(*owner) : nullptr; +} + +MaterialDescriptorResources makeResources(std::uintptr_t base) noexcept +{ + MaterialDescriptorResources resources; + resources.mParameters = { fakeHandle(base), MATERIAL_PARAMETER_SIZE, 0 }; + for (std::size_t index = 0; index < resources.mSampledImages.size(); ++index) + { + resources.mSampledImages[index] = { fakeHandle(base + 0x10U + index * 2U), + fakeHandle(base + 0x11U + index * 2U) }; + } + return resources; +} + +const MaterialDescriptorCreationError* creationError(const MaterialDescriptorCreationResult& result) noexcept +{ + return std::get_if(&result); +} + +std::unique_ptr* createdGeneration(MaterialDescriptorCreationResult& result) noexcept +{ + return std::get_if>(&result); +} + +void ensureCreationError(const char* message, const MaterialDescriptorCreationResult& result, MaterialDescriptorCreationCode code, + std::optional tuple_index = std::nullopt, std::optional sampled_index = std::nullopt, + VkResult native_result = VK_SUCCESS) +{ + const auto* error = creationError(result); + tut::ensure(message, error && error->mCode == code && error->mTupleIndex == tuple_index && error->mSampledImageIndex == sampled_index && + error->mResult == native_result); +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_material_descriptor_test +{ +}; + +using render_vulkan_material_descriptor_group = test_group; +using render_vulkan_material_descriptor_object = render_vulkan_material_descriptor_group::object; +render_vulkan_material_descriptor_group render_vulkan_material_descriptor_tests("render Vulkan material descriptors"); + +template<> +template<> +void render_vulkan_material_descriptor_object::test<1>() +{ + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_constructible_v); + static_assert(!std::is_move_assignable_v); + static_assert(std::is_nothrow_destructible_v); + static_assert(noexcept(validMaterialDescriptorGenerationCount(std::size_t{}))); + static_assert(noexcept(createLegacyNormSpecDescriptorGeneration(std::declval(), + std::declval(), + std::declval&>()))); + static_assert(std::variant_size_v == 2); + static_assert(std::is_same_v, MaterialDescriptorCreationError>); + static_assert(std::is_same_v, + std::unique_ptr>); + + constexpr std::size_t max_count = std::numeric_limits::max() / 4U; + ensure("one tuple is a valid generation", validMaterialDescriptorGenerationCount(1)); + ensure("the largest exact write count is valid", validMaterialDescriptorGenerationCount(max_count)); + ensure("an empty generation is invalid", !validMaterialDescriptorGenerationCount(0)); + ensure("a write-count overflow is invalid", !validMaterialDescriptorGenerationCount(max_count + 1U)); +} + +template<> +template<> +void render_vulkan_material_descriptor_object::test<2>() +{ + FakeState state; + auto layout = makeLayout(state); + ensure("the preflight fixture has canonical layouts", layout != nullptr); + const std::vector resources{ makeResources(0x5000U) }; + + auto result = createLegacyNormSpecDescriptorGeneration({}, *layout, resources); + ensureCreationError("a null device is rejected", result, MaterialDescriptorCreationCode::InvalidDevice); + + MaterialDescriptorDevice device = fakeDescriptorDevice(state); + device.mDispatch.mCreateDescriptorPool = nullptr; + result = createLegacyNormSpecDescriptorGeneration(device, *layout, resources); + ensureCreationError("missing pool creation is rejected", result, MaterialDescriptorCreationCode::InvalidDispatch); + + device = fakeDescriptorDevice(state); + device.mDispatch.mDestroyDescriptorPool = nullptr; + result = createLegacyNormSpecDescriptorGeneration(device, *layout, resources); + ensureCreationError("missing pool destruction is rejected", result, MaterialDescriptorCreationCode::InvalidDispatch); + + device = fakeDescriptorDevice(state); + device.mDispatch.mAllocateDescriptorSets = nullptr; + result = createLegacyNormSpecDescriptorGeneration(device, *layout, resources); + ensureCreationError("missing set allocation is rejected", result, MaterialDescriptorCreationCode::InvalidDispatch); + + device = fakeDescriptorDevice(state); + device.mDispatch.mUpdateDescriptorSets = nullptr; + result = createLegacyNormSpecDescriptorGeneration(device, *layout, resources); + ensureCreationError("missing descriptor update is rejected", result, MaterialDescriptorCreationCode::InvalidDispatch); + + FakeState other_state; + result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(other_state), *layout, resources); + ensureCreationError("layouts from another device are rejected", result, MaterialDescriptorCreationCode::LayoutDeviceMismatch); + + result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, {}); + ensureCreationError("an empty batch is rejected", result, MaterialDescriptorCreationCode::EmptyBatch); + + std::vector invalid = resources; + invalid[0].mParameters.mBuffer = VK_NULL_HANDLE; + result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, invalid); + ensureCreationError("a null uniform buffer is rejected", result, MaterialDescriptorCreationCode::InvalidUniformBuffer, 0); + + invalid = resources; + invalid[0].mParameters.mOffset = 1; + result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, invalid); + ensureCreationError("a uniform block extending past its declared buffer is rejected", result, + MaterialDescriptorCreationCode::InvalidUniformRange, 0); + + invalid = resources; + invalid[0].mParameters.mSize = MATERIAL_PARAMETER_SIZE - 1U; + result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, invalid); + ensureCreationError("a buffer smaller than the canonical uniform block is rejected", result, + MaterialDescriptorCreationCode::InvalidUniformRange, 0); + + invalid = resources; + invalid[0].mParameters.mSize = std::numeric_limits::max(); + invalid[0].mParameters.mOffset = std::numeric_limits::max(); + result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, invalid); + ensureCreationError("uniform range arithmetic cannot wrap around", result, MaterialDescriptorCreationCode::InvalidUniformRange, 0); + + for (std::uint32_t index = 0; index < 3; ++index) + { + invalid = resources; + invalid[0].mSampledImages[index].mView = VK_NULL_HANDLE; + result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, invalid); + ensureCreationError("each null sampled image is rejected", result, MaterialDescriptorCreationCode::InvalidSampledImage, 0, index); + + invalid = resources; + invalid[0].mSampledImages[index].mSampler = VK_NULL_HANDLE; + result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, invalid); + ensureCreationError("each null sampler is rejected", result, MaterialDescriptorCreationCode::InvalidSampler, 0, index); + } + + invalid = { resources[0], resources[0] }; + invalid[1].mSampledImages[2].mSampler = VK_NULL_HANDLE; + result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, invalid); + ensureCreationError("resource errors retain later tuple and sampled-image context", result, + MaterialDescriptorCreationCode::InvalidSampler, 1, 2); + + ensure_equals("preflight rejection creates no descriptor pool", state.mPoolCreateCount, std::size_t{ 0 }); + ensure_equals("preflight rejection allocates no descriptor sets", state.mAllocateCount, std::size_t{ 0 }); + ensure_equals("preflight rejection writes no descriptors", state.mUpdateCount, std::size_t{ 0 }); + ensure_equals("device mismatch creates no descriptor pool", other_state.mPoolCreateCount, std::size_t{ 0 }); +} + +template<> +template<> +void render_vulkan_material_descriptor_object::test<3>() +{ + FakeState state; + auto layout = makeLayout(state); + ensure("the population fixture has canonical layouts", layout != nullptr); + std::vector resources{ makeResources(0x5000U), makeResources(0x6000U) }; + resources[1].mParameters.mSize += 64U; + resources[1].mParameters.mOffset = 64U; + + auto result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, resources); + auto* owner = createdGeneration(result); + ensure("two descriptor tuples are populated", owner && *owner); + ensure_equals("one pool is created", state.mPoolCreateCount, std::size_t{ 1 }); + ensure_equals("all sets are allocated in one call", state.mAllocateCount, std::size_t{ 1 }); + ensure_equals("all descriptors are written in one call", state.mUpdateCount, std::size_t{ 1 }); + ensure("the observation buffers did not overflow", !state.mObservationOverflow); + + const PoolCreateObservation& pool = state.mPoolCreates[0]; + ensure("the immutable arena pool uses exact capacity and no individual-free flag", + pool.mDevice == fakeDescriptorDevice(state).mDevice && pool.mType == VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO && + pool.mNext == nullptr && pool.mFlags == 0 && pool.mMaxSets == 4 && pool.mSizeCount == 2 && pool.mAllocator == nullptr); + ensure("the pool has two uniform descriptors", + pool.mSizes[0].type == VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER && pool.mSizes[0].descriptorCount == 2); + ensure("the pool has six combined image samplers", + pool.mSizes[1].type == VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER && pool.mSizes[1].descriptorCount == 6); + + const SetAllocateObservation& allocation = state.mAllocates[0]; + const auto layouts = layout->descriptorSetLayouts(); + ensure("the allocation uses the new pool and four alternating layouts", + allocation.mDevice == fakeDescriptorDevice(state).mDevice && + allocation.mType == VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO && allocation.mNext == nullptr && + allocation.mPool == state.mPoolOutputs[0] && allocation.mSetCount == 4 && allocation.mLayouts[0] == layouts[0] && + allocation.mLayouts[1] == layouts[1] && allocation.mLayouts[2] == layouts[0] && allocation.mLayouts[3] == layouts[1]); + + const UpdateObservation& update = state.mUpdates[0]; + ensure("the population call writes four descriptors per tuple and no copies", + update.mDevice == fakeDescriptorDevice(state).mDevice && update.mWriteCount == 8 && update.mCopyCount == 0 && + update.mCopies == nullptr); + for (std::size_t tuple = 0; tuple < resources.size(); ++tuple) + { + const WriteObservation& uniform = update.mWrites[tuple * 4]; + ensure("the uniform write targets set zero binding zero", + uniform.mType == VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET && uniform.mNext == nullptr && + uniform.mSet == state.mSetOutputs[0][tuple * 2] && uniform.mBinding == 0 && uniform.mArrayElement == 0 && + uniform.mDescriptorCount == 1 && uniform.mDescriptorType == VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER && + !uniform.mHasImageInfo && uniform.mHasBufferInfo && !uniform.mHasTexelView && + uniform.mBuffer.buffer == resources[tuple].mParameters.mBuffer && + uniform.mBuffer.offset == resources[tuple].mParameters.mOffset && uniform.mBuffer.range == MATERIAL_PARAMETER_SIZE); + + for (std::size_t sampled = 0; sampled < 3; ++sampled) + { + const WriteObservation& image = update.mWrites[tuple * 4 + sampled + 1]; + ensure("each sampled write targets its set-one binding", + image.mType == VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET && image.mNext == nullptr && + image.mSet == state.mSetOutputs[0][tuple * 2 + 1] && image.mBinding == sampled && image.mArrayElement == 0 && + image.mDescriptorCount == 1 && image.mDescriptorType == VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER && + image.mHasImageInfo && !image.mHasBufferInfo && !image.mHasTexelView && + image.mImage.sampler == resources[tuple].mSampledImages[sampled].mSampler && + image.mImage.imageView == resources[tuple].mSampledImages[sampled].mView && + image.mImage.imageLayout == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + } + + const auto binding = (*owner)->binding(tuple); + ensure("the owner returns each ordered set pair and copied resource tuple", + binding && binding->mSets.mParameters == state.mSetOutputs[0][tuple * 2] && + binding->mSets.mSampledImages == state.mSetOutputs[0][tuple * 2 + 1] && binding->mResources == resources[tuple]); + } + ensure("an out-of-range binding is absent", !(*owner)->binding(resources.size())); + ensure_equals("live generation retains its pool", state.mPoolDestroyCount, std::size_t{ 0 }); + + std::unique_ptr first = std::move(*owner); + std::unique_ptr final = std::move(first); + ensure("unique ownership transfers without moving the native owner", !first && final); + final.reset(); + ensure_equals("final release destroys the pool once", state.mPoolDestroyCount, std::size_t{ 1 }); + ensure("pool destruction uses its creating device, exact pool, and null allocator", + state.mPoolDestroys[0].mDevice == fakeDescriptorDevice(state).mDevice && state.mPoolDestroys[0].mPool == state.mPoolOutputs[0] && + state.mPoolDestroys[0].mAllocator == nullptr); +} + +template<> +template<> +void render_vulkan_material_descriptor_object::test<4>() +{ + FakeState state; + auto layout = makeLayout(state); + ensure("the pool-failure fixture has canonical layouts", layout != nullptr); + const std::vector resources{ makeResources(0x5000U) }; + + state.mPoolResults[0] = VK_ERROR_OUT_OF_HOST_MEMORY; + state.mPoolOutputs[0] = fakeHandle(0xdeadU); + auto result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, resources); + ensureCreationError("pool failure preserves its native result", result, MaterialDescriptorCreationCode::PoolCreateFailure, std::nullopt, + std::nullopt, VK_ERROR_OUT_OF_HOST_MEMORY); + ensure_equals("a poisoned failure output is not owned", state.mPoolDestroyCount, std::size_t{ 0 }); + ensure_equals("pool failure allocates no sets", state.mAllocateCount, std::size_t{ 0 }); + ensure_equals("pool failure writes no descriptors", state.mUpdateCount, std::size_t{ 0 }); + + FakeState null_state; + auto null_layout = makeLayout(null_state); + ensure("the null-pool fixture has canonical layouts", null_layout != nullptr); + null_state.mPoolOutputs[0] = VK_NULL_HANDLE; + result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(null_state), *null_layout, resources); + ensureCreationError("success with a null pool fails closed", result, MaterialDescriptorCreationCode::NullPool); + ensure_equals("a null pool has no destruction obligation", null_state.mPoolDestroyCount, std::size_t{ 0 }); + ensure_equals("a null pool allocates no sets", null_state.mAllocateCount, std::size_t{ 0 }); +} + +template<> +template<> +void render_vulkan_material_descriptor_object::test<5>() +{ + FakeState state; + auto layout = makeLayout(state); + ensure("the allocation-failure fixture has canonical layouts", layout != nullptr); + const std::vector resources{ makeResources(0x5000U), makeResources(0x6000U) }; + state.mAllocateResults[0] = VK_ERROR_OUT_OF_DEVICE_MEMORY; + state.mSetOutputs[0][0] = fakeHandle(0xdeadU); + + auto result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, resources); + ensureCreationError("set allocation failure preserves its native result", result, MaterialDescriptorCreationCode::SetAllocationFailure, + std::nullopt, std::nullopt, VK_ERROR_OUT_OF_DEVICE_MEMORY); + ensure_equals("set allocation is attempted once", state.mAllocateCount, std::size_t{ 1 }); + ensure_equals("allocation failure writes no descriptors", state.mUpdateCount, std::size_t{ 0 }); + ensure_equals("allocation failure rolls back the pool once", state.mPoolDestroyCount, std::size_t{ 1 }); + ensure("rollback destroys the successful pool, not poisoned set outputs", state.mPoolDestroys[0].mPool == state.mPoolOutputs[0]); +} + +template<> +template<> +void render_vulkan_material_descriptor_object::test<6>() +{ + const std::array null_positions{ 0, 2, 3 }; + for (std::size_t null_position : null_positions) + { + FakeState state; + auto layout = makeLayout(state); + ensure("the null-set fixture has canonical layouts", layout != nullptr); + const std::vector resources{ makeResources(0x5000U), makeResources(0x6000U) }; + state.mSetOutputs[0][null_position] = VK_NULL_HANDLE; + + auto result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, resources); + const bool parameter_set = (null_position % 2U) == 0; + ensureCreationError("a null allocated set fails closed", result, + parameter_set ? MaterialDescriptorCreationCode::NullParameterSet + : MaterialDescriptorCreationCode::NullSampledImageSet, + null_position / 2U); + ensure_equals("a null allocated set prevents all descriptor writes", state.mUpdateCount, std::size_t{ 0 }); + ensure_equals("a null allocated set rolls back the whole pool", state.mPoolDestroyCount, std::size_t{ 1 }); + } +} + +template<> +template<> +void render_vulkan_material_descriptor_object::test<7>() +{ + FakeState state; + auto layout = makeLayout(state); + ensure("the opaque-alias fixture has canonical layouts", layout != nullptr); + const MaterialDescriptorResources shared = makeResources(0x5000U); + const std::vector resources{ shared, shared }; + const VkDescriptorSet repeated_set = fakeHandle(0x7777U); + state.mSetOutputs[0].fill(repeated_set); + + auto result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, resources); + auto* owner = createdGeneration(result); + ensure("equal non-dispatchable set and resource values are accepted", owner && *owner); + ensure_equals("equal values still populate every descriptor", state.mUpdates[0].mWriteCount, std::uint32_t{ 8 }); + for (std::size_t tuple = 0; tuple < resources.size(); ++tuple) + { + const auto binding = (*owner)->binding(tuple); + ensure("each logical binding retains the repeated opaque values", + binding && binding->mSets.mParameters == repeated_set && binding->mSets.mSampledImages == repeated_set && + binding->mResources == shared); + } + owner->reset(); + ensure_equals("equal set values do not alter pool ownership", state.mPoolDestroyCount, std::size_t{ 1 }); +} + +template<> +template<> +void render_vulkan_material_descriptor_object::test<8>() +{ + FakeState state; + auto layout = makeLayout(state); + ensure("the copied-metadata fixture has canonical layouts", layout != nullptr); + std::vector resources{ makeResources(0x5000U) }; + const MaterialDescriptorResources expected = resources[0]; + + auto result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, resources); + auto* owner = createdGeneration(result); + ensure("the descriptor generation is created", owner && *owner); + resources[0] = {}; + + const auto binding = (*owner)->binding(0); + ensure("borrowed native handles are copied as stable metadata", + binding && binding->mResources == expected && binding->mSets.mParameters == state.mSetOutputs[0][0] && + binding->mSets.mSampledImages == state.mSetOutputs[0][1]); +} + +template<> +template<> +void render_vulkan_material_descriptor_object::test<9>() +{ + FakeState state; + auto layout = makeLayout(state); + ensure("the independent-generation fixture has canonical layouts", layout != nullptr); + const std::vector resources{ makeResources(0x5000U) }; + state.mPoolOutputs[1] = state.mPoolOutputs[0]; + state.mSetOutputs[1] = state.mSetOutputs[0]; + + auto first_result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, resources); + auto second_result = createLegacyNormSpecDescriptorGeneration(fakeDescriptorDevice(state), *layout, resources); + auto* first_ptr = createdGeneration(first_result); + auto* second_ptr = createdGeneration(second_result); + ensure("repeated opaque values still produce independent owners", + first_ptr && *first_ptr && second_ptr && *second_ptr && first_ptr->get() != second_ptr->get()); + ensure_equals("the factory has no hidden pool cache", state.mPoolCreateCount, std::size_t{ 2 }); + ensure_equals("each generation allocates its own logical set batch", state.mAllocateCount, std::size_t{ 2 }); + ensure_equals("each generation populates its descriptors", state.mUpdateCount, std::size_t{ 2 }); + + second_ptr->reset(); + first_ptr->reset(); + ensure_equals("both successful pool creates have a destruction obligation", state.mPoolDestroyCount, std::size_t{ 2 }); + ensure("the repeated pool value is destroyed once for each successful create", + state.mPoolDestroys[0].mPool == state.mPoolOutputs[0] && state.mPoolDestroys[1].mPool == state.mPoolOutputs[0]); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendervulkanmateriallayout_test.cpp b/indra/llrender/tests/llrendervulkanmateriallayout_test.cpp new file mode 100644 index 00000000000..d7f7e495562 --- /dev/null +++ b/indra/llrender/tests/llrendervulkanmateriallayout_test.cpp @@ -0,0 +1,531 @@ +/** + * @file llrendervulkanmateriallayout_test.cpp + * @brief Tests for transactional Vulkan material descriptor and pipeline layouts. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendervulkanmateriallayout.h" +#include "llshadermanifest.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; +using namespace LLRenderVulkanMaterial; + +template +Handle fakeHandle(std::uintptr_t value) noexcept +{ + if constexpr (std::is_pointer_v) + { + return reinterpret_cast(value); + } + else + { + return static_cast(value); + } +} + +VkShaderStageFlags vulkanStages(const ShaderStageVisibility& visibility) noexcept +{ + VkShaderStageFlags flags = 0; + if (visibility.mVertex) + { + flags |= VK_SHADER_STAGE_VERTEX_BIT; + } + if (visibility.mFragment) + { + flags |= VK_SHADER_STAGE_FRAGMENT_BIT; + } + return flags; +} + +enum class EventKind : std::uint8_t +{ + CreateDescriptorSetLayout, + CreatePipelineLayout, + DestroyPipelineLayout, + DestroyDescriptorSetLayout +}; + +struct Event +{ + EventKind mKind = EventKind::CreateDescriptorSetLayout; + VkDevice mDevice = VK_NULL_HANDLE; + VkDescriptorSetLayout mDescriptorLayout = VK_NULL_HANDLE; + VkPipelineLayout mPipelineLayout = VK_NULL_HANDLE; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct DescriptorCreateObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkStructureType mType = VK_STRUCTURE_TYPE_MAX_ENUM; + const void* mNext = nullptr; + VkDescriptorSetLayoutCreateFlags mFlags = 0; + std::array mBindings{}; + std::array mHasImmutableSamplers{}; + std::uint32_t mBindingCount = 0; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct PipelineCreateObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkStructureType mType = VK_STRUCTURE_TYPE_MAX_ENUM; + const void* mNext = nullptr; + VkPipelineLayoutCreateFlags mFlags = 0; + std::array mSetLayouts{}; + std::uint32_t mSetLayoutCount = 0; + std::uint32_t mPushConstantRangeCount = 0; + const VkPushConstantRange* mPushConstantRanges = nullptr; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct FakeState +{ + static constexpr std::size_t MAX_DESCRIPTOR_CALLS = 8; + static constexpr std::size_t MAX_PIPELINE_CALLS = 4; + static constexpr std::size_t MAX_EVENTS = 24; + + FakeState() + { + for (std::size_t index = 0; index < MAX_DESCRIPTOR_CALLS; ++index) + { + mDescriptorOutputs[index] = fakeHandle(0x1001U + index); + } + for (std::size_t index = 0; index < MAX_PIPELINE_CALLS; ++index) + { + mPipelineOutputs[index] = fakeHandle(0x2001U + index); + } + } + + std::array mDescriptorResults{}; + std::array mDescriptorOutputs{}; + std::array mDescriptorCreates{}; + std::array mPipelineResults{}; + std::array mPipelineOutputs{}; + std::array mPipelineCreates{}; + std::array mEvents{}; + std::size_t mDescriptorCreateCount = 0; + std::size_t mDescriptorDestroyCount = 0; + std::size_t mPipelineCreateCount = 0; + std::size_t mPipelineDestroyCount = 0; + std::size_t mEventCount = 0; +}; + +VKAPI_ATTR VkResult VKAPI_CALL fakeCreateDescriptorSetLayout(VkDevice device, const VkDescriptorSetLayoutCreateInfo* info, + const VkAllocationCallbacks* allocator, VkDescriptorSetLayout* output) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || !info || !output || state->mDescriptorCreateCount >= FakeState::MAX_DESCRIPTOR_CALLS || info->bindingCount > 3 || + (info->bindingCount != 0 && !info->pBindings) || state->mEventCount >= FakeState::MAX_EVENTS) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + + const std::size_t index = state->mDescriptorCreateCount++; + auto& observation = state->mDescriptorCreates[index]; + observation.mDevice = device; + observation.mType = info->sType; + observation.mNext = info->pNext; + observation.mFlags = info->flags; + observation.mBindingCount = info->bindingCount; + observation.mAllocator = allocator; + for (std::uint32_t binding = 0; binding < info->bindingCount; ++binding) + { + observation.mBindings[binding] = info->pBindings[binding]; + observation.mHasImmutableSamplers[binding] = info->pBindings[binding].pImmutableSamplers != nullptr; + observation.mBindings[binding].pImmutableSamplers = nullptr; + } + + *output = state->mDescriptorOutputs[index]; + state->mEvents[state->mEventCount++] = { EventKind::CreateDescriptorSetLayout, device, *output, VK_NULL_HANDLE, allocator }; + return state->mDescriptorResults[index]; +} + +VKAPI_ATTR void VKAPI_CALL fakeDestroyDescriptorSetLayout(VkDevice device, VkDescriptorSetLayout layout, + const VkAllocationCallbacks* allocator) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || state->mEventCount >= FakeState::MAX_EVENTS) + { + return; + } + ++state->mDescriptorDestroyCount; + state->mEvents[state->mEventCount++] = { EventKind::DestroyDescriptorSetLayout, device, layout, VK_NULL_HANDLE, allocator }; +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeCreatePipelineLayout(VkDevice device, const VkPipelineLayoutCreateInfo* info, + const VkAllocationCallbacks* allocator, VkPipelineLayout* output) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || !info || !output || state->mPipelineCreateCount >= FakeState::MAX_PIPELINE_CALLS || info->setLayoutCount > 2 || + (info->setLayoutCount != 0 && !info->pSetLayouts) || state->mEventCount >= FakeState::MAX_EVENTS) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + + const std::size_t index = state->mPipelineCreateCount++; + auto& observation = state->mPipelineCreates[index]; + observation.mDevice = device; + observation.mType = info->sType; + observation.mNext = info->pNext; + observation.mFlags = info->flags; + observation.mSetLayoutCount = info->setLayoutCount; + observation.mPushConstantRangeCount = info->pushConstantRangeCount; + observation.mPushConstantRanges = info->pPushConstantRanges; + observation.mAllocator = allocator; + for (std::uint32_t set = 0; set < info->setLayoutCount; ++set) + { + observation.mSetLayouts[set] = info->pSetLayouts[set]; + } + + *output = state->mPipelineOutputs[index]; + state->mEvents[state->mEventCount++] = { EventKind::CreatePipelineLayout, device, VK_NULL_HANDLE, *output, allocator }; + return state->mPipelineResults[index]; +} + +VKAPI_ATTR void VKAPI_CALL fakeDestroyPipelineLayout(VkDevice device, VkPipelineLayout layout, + const VkAllocationCallbacks* allocator) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || state->mEventCount >= FakeState::MAX_EVENTS) + { + return; + } + ++state->mPipelineDestroyCount; + state->mEvents[state->mEventCount++] = { EventKind::DestroyPipelineLayout, device, VK_NULL_HANDLE, layout, allocator }; +} + +MaterialLayoutDevice fakeDevice(FakeState& state) noexcept +{ + return { reinterpret_cast(&state), + { fakeCreateDescriptorSetLayout, fakeDestroyDescriptorSetLayout, fakeCreatePipelineLayout, fakeDestroyPipelineLayout } }; +} + +const MaterialLayoutCreationError* creationError(const MaterialLayoutCreationResult& result) noexcept +{ + return std::get_if(&result); +} + +std::unique_ptr* createdLayout(MaterialLayoutCreationResult& result) noexcept +{ + return std::get_if>(&result); +} + +void ensureCreationError(const char* message, const MaterialLayoutCreationResult& result, MaterialLayoutCreationCode code, + std::optional object, VkResult native_result) +{ + const auto* error = creationError(result); + tut::ensure(message, error && error->mCode == code && error->mObject == object && error->mResult == native_result); +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_material_layout_test +{ +}; + +using render_vulkan_material_layout_test_group = test_group; +using render_vulkan_material_layout_test_object = render_vulkan_material_layout_test_group::object; +render_vulkan_material_layout_test_group render_vulkan_material_layout_tests("render vulkan material layout"); + +template<> +template<> +void render_vulkan_material_layout_test_object::test<1>() +{ + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_constructible_v); + static_assert(!std::is_move_assignable_v); + static_assert(std::is_nothrow_destructible_v); + static_assert(noexcept(createLegacyNormSpecPipelineLayout(std::declval()))); + static_assert(std::variant_size_v == 2); + static_assert(std::is_same_v, MaterialLayoutCreationError>); + static_assert( + std::is_same_v, std::unique_ptr>); +} + +template<> +template<> +void render_vulkan_material_layout_test_object::test<2>() +{ + FakeState state; + + auto result = createLegacyNormSpecPipelineLayout({}); + ensureCreationError("a null device is rejected", result, MaterialLayoutCreationCode::InvalidDevice, std::nullopt, VK_SUCCESS); + + MaterialLayoutDevice device = fakeDevice(state); + device.mDispatch.mCreateDescriptorSetLayout = nullptr; + result = createLegacyNormSpecPipelineLayout(device); + ensureCreationError("missing descriptor creation is rejected", result, MaterialLayoutCreationCode::InvalidDispatch, std::nullopt, + VK_SUCCESS); + + device = fakeDevice(state); + device.mDispatch.mDestroyDescriptorSetLayout = nullptr; + result = createLegacyNormSpecPipelineLayout(device); + ensureCreationError("missing descriptor destruction is rejected", result, MaterialLayoutCreationCode::InvalidDispatch, std::nullopt, + VK_SUCCESS); + + device = fakeDevice(state); + device.mDispatch.mCreatePipelineLayout = nullptr; + result = createLegacyNormSpecPipelineLayout(device); + ensureCreationError("missing pipeline creation is rejected", result, MaterialLayoutCreationCode::InvalidDispatch, std::nullopt, + VK_SUCCESS); + + device = fakeDevice(state); + device.mDispatch.mDestroyPipelineLayout = nullptr; + result = createLegacyNormSpecPipelineLayout(device); + ensureCreationError("missing pipeline destruction is rejected", result, MaterialLayoutCreationCode::InvalidDispatch, std::nullopt, + VK_SUCCESS); + + ensure_equals("preflight rejection makes no native call", state.mEventCount, std::size_t{ 0 }); +} + +template<> +template<> +void render_vulkan_material_layout_test_object::test<3>() +{ + FakeState state; + const MaterialLayoutDevice device = fakeDevice(state); + auto result = createLegacyNormSpecPipelineLayout(device); + auto* owner = createdLayout(result); + ensure("the canonical layouts are created", owner && *owner); + + ensure_equals("two descriptor layouts are created", state.mDescriptorCreateCount, std::size_t{ 2 }); + ensure_equals("one pipeline layout is created", state.mPipelineCreateCount, std::size_t{ 1 }); + ensure_equals("creation does not destroy live layouts", state.mDescriptorDestroyCount + state.mPipelineDestroyCount, std::size_t{ 0 }); + + const auto manifest = legacyNormSpecShaderManifest(legacyNormSpecModernHDRPipelineKey(), ShaderBackend::Vulkan); + ensure("the canonical production manifest resolves", manifest && validLegacyNormSpecProductionShaderManifest(*manifest)); + ensure("the production manifest has one parameter block and three sampled images", + manifest->mParameterBlock && manifest->mSampledImages.size() == 3 && manifest->mPushConstantRanges.empty()); + + const DescriptorCreateObservation& parameters = state.mDescriptorCreates[0]; + ensure("parameter set create info is canonical", + parameters.mDevice == device.mDevice && parameters.mType == VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO && + parameters.mNext == nullptr && parameters.mFlags == 0 && parameters.mBindingCount == 1 && parameters.mAllocator == nullptr); + const VkDescriptorSetLayoutBinding& parameter_binding = parameters.mBindings[0]; + ensure("set zero follows the production parameter block", + manifest->mParameterBlock->mSet == 0 && manifest->mParameterBlock->mBinding == 0 && + manifest->mParameterBlock->mVisibility == ShaderStageVisibility{ true, true } && parameter_binding.binding == 0 && + parameter_binding.descriptorType == VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER && parameter_binding.descriptorCount == 1 && + parameter_binding.stageFlags == (VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT) && + parameter_binding.stageFlags == vulkanStages(manifest->mParameterBlock->mVisibility) && + !parameters.mHasImmutableSamplers[0]); + + const DescriptorCreateObservation& images = state.mDescriptorCreates[1]; + ensure("sampled-image set create info is canonical", + images.mDevice == device.mDevice && images.mType == VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO && + images.mNext == nullptr && images.mFlags == 0 && images.mBindingCount == manifest->mSampledImages.size() && + images.mAllocator == nullptr); + for (std::size_t index = 0; index < manifest->mSampledImages.size(); ++index) + { + const ShaderSampledImage& expected = manifest->mSampledImages[index]; + const VkDescriptorSetLayoutBinding& observed = images.mBindings[index]; + ensure("sampled images are the three fragment-visible bindings in set one", + expected.mSet == 1 && expected.mBinding == index && expected.mVisibility == ShaderStageVisibility{ false, true }); + ensure("each sampled binding follows the production manifest", + observed.binding == expected.mBinding && observed.descriptorType == VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER && + observed.descriptorCount == 1 && observed.stageFlags == VK_SHADER_STAGE_FRAGMENT_BIT && + observed.stageFlags == vulkanStages(expected.mVisibility) && !images.mHasImmutableSamplers[index]); + } + + const PipelineCreateObservation& pipeline = state.mPipelineCreates[0]; + const auto exposed = (*owner)->descriptorSetLayouts(); + ensure("pipeline layout create info is canonical", + pipeline.mDevice == device.mDevice && pipeline.mType == VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO && + pipeline.mNext == nullptr && pipeline.mFlags == 0 && pipeline.mSetLayoutCount == 2 && + pipeline.mPushConstantRangeCount == 0 && pipeline.mPushConstantRanges == nullptr && pipeline.mAllocator == nullptr); + ensure("descriptor set order is parameter then sampled image", + pipeline.mSetLayouts[0] == state.mDescriptorOutputs[0] && pipeline.mSetLayouts[1] == state.mDescriptorOutputs[1] && + exposed[0] == state.mDescriptorOutputs[0] && exposed[1] == state.mDescriptorOutputs[1]); + ensure("the owner exposes exact native handles", + (*owner)->parameterSetLayout() == state.mDescriptorOutputs[0] && + (*owner)->sampledImageSetLayout() == state.mDescriptorOutputs[1] && (*owner)->pipelineLayout() == state.mPipelineOutputs[0]); + ensure("the owner reports only its creating device", + (*owner)->createdOn(device.mDevice) && !(*owner)->createdOn(fakeHandle(0x5eedU))); + ensure("creation order is parameter set, sampled-image set, then pipeline", + state.mEvents[0].mKind == EventKind::CreateDescriptorSetLayout && + state.mEvents[0].mDescriptorLayout == state.mDescriptorOutputs[0] && + state.mEvents[1].mKind == EventKind::CreateDescriptorSetLayout && + state.mEvents[1].mDescriptorLayout == state.mDescriptorOutputs[1] && + state.mEvents[2].mKind == EventKind::CreatePipelineLayout && state.mEvents[2].mPipelineLayout == state.mPipelineOutputs[0]); +} + +template<> +template<> +void render_vulkan_material_layout_test_object::test<4>() +{ + FakeState state; + state.mDescriptorResults[0] = VK_ERROR_OUT_OF_HOST_MEMORY; + state.mDescriptorOutputs[0] = fakeHandle(0x3001U); + auto result = createLegacyNormSpecPipelineLayout(fakeDevice(state)); + ensureCreationError("parameter layout failure preserves its object and result", result, MaterialLayoutCreationCode::CreateFailure, + MaterialLayoutObject::ParameterSetLayout, VK_ERROR_OUT_OF_HOST_MEMORY); + ensure_equals("parameter failure stops after one call", state.mDescriptorCreateCount, std::size_t{ 1 }); + ensure_equals("a poisoned failure output is not owned", state.mDescriptorDestroyCount, std::size_t{ 0 }); + + FakeState null_state; + null_state.mDescriptorOutputs[0] = VK_NULL_HANDLE; + result = createLegacyNormSpecPipelineLayout(fakeDevice(null_state)); + ensureCreationError("success with a null parameter layout fails closed", result, MaterialLayoutCreationCode::NullHandle, + MaterialLayoutObject::ParameterSetLayout, VK_SUCCESS); + ensure_equals("a null parameter layout has nothing to roll back", null_state.mDescriptorDestroyCount, std::size_t{ 0 }); +} + +template<> +template<> +void render_vulkan_material_layout_test_object::test<5>() +{ + FakeState state; + state.mDescriptorResults[1] = VK_ERROR_OUT_OF_DEVICE_MEMORY; + state.mDescriptorOutputs[1] = state.mDescriptorOutputs[0]; + auto result = createLegacyNormSpecPipelineLayout(fakeDevice(state)); + ensureCreationError("sampled layout failure preserves its object and result", result, MaterialLayoutCreationCode::CreateFailure, + MaterialLayoutObject::SampledImageSetLayout, VK_ERROR_OUT_OF_DEVICE_MEMORY); + ensure_equals("sampled failure attempts both descriptor layouts", state.mDescriptorCreateCount, std::size_t{ 2 }); + ensure_equals("sampled failure rolls back only the parameter layout", state.mDescriptorDestroyCount, std::size_t{ 1 }); + ensure("a poisoned sampled output alias does not duplicate the real rollback obligation", + state.mEvents[2].mKind == EventKind::DestroyDescriptorSetLayout && + state.mEvents[2].mDescriptorLayout == state.mDescriptorOutputs[0] && state.mDescriptorDestroyCount == 1); + + FakeState null_state; + null_state.mDescriptorOutputs[1] = VK_NULL_HANDLE; + result = createLegacyNormSpecPipelineLayout(fakeDevice(null_state)); + ensureCreationError("success with a null sampled layout fails closed", result, MaterialLayoutCreationCode::NullHandle, + MaterialLayoutObject::SampledImageSetLayout, VK_SUCCESS); + ensure_equals("a null sampled layout rolls back the parameter layout", null_state.mDescriptorDestroyCount, std::size_t{ 1 }); + ensure("null sampled rollback destroys the exact parameter layout", + null_state.mEvents[2].mDescriptorLayout == null_state.mDescriptorOutputs[0]); +} + +template<> +template<> +void render_vulkan_material_layout_test_object::test<6>() +{ + FakeState state; + state.mPipelineResults[0] = VK_ERROR_OUT_OF_HOST_MEMORY; + state.mPipelineOutputs[0] = fakeHandle(0x4001U); + auto result = createLegacyNormSpecPipelineLayout(fakeDevice(state)); + ensureCreationError("pipeline layout failure preserves its object and result", result, MaterialLayoutCreationCode::CreateFailure, + MaterialLayoutObject::PipelineLayout, VK_ERROR_OUT_OF_HOST_MEMORY); + ensure_equals("pipeline failure is attempted once", state.mPipelineCreateCount, std::size_t{ 1 }); + ensure_equals("a poisoned pipeline output is not owned", state.mPipelineDestroyCount, std::size_t{ 0 }); + ensure_equals("pipeline failure rolls back both descriptor layouts", state.mDescriptorDestroyCount, std::size_t{ 2 }); + ensure("pipeline failure rollback is sampled then parameter", + state.mEvents[3].mKind == EventKind::DestroyDescriptorSetLayout && + state.mEvents[3].mDescriptorLayout == state.mDescriptorOutputs[1] && + state.mEvents[4].mKind == EventKind::DestroyDescriptorSetLayout && + state.mEvents[4].mDescriptorLayout == state.mDescriptorOutputs[0]); + + FakeState null_state; + null_state.mPipelineOutputs[0] = VK_NULL_HANDLE; + result = createLegacyNormSpecPipelineLayout(fakeDevice(null_state)); + ensureCreationError("success with a null pipeline layout fails closed", result, MaterialLayoutCreationCode::NullHandle, + MaterialLayoutObject::PipelineLayout, VK_SUCCESS); + ensure_equals("a null pipeline layout is not destroyed", null_state.mPipelineDestroyCount, std::size_t{ 0 }); + ensure_equals("a null pipeline layout rolls back both descriptor layouts", null_state.mDescriptorDestroyCount, std::size_t{ 2 }); +} + +template<> +template<> +void render_vulkan_material_layout_test_object::test<7>() +{ + FakeState state; + state.mDescriptorOutputs[1] = state.mDescriptorOutputs[0]; + + auto result = createLegacyNormSpecPipelineLayout(fakeDevice(state)); + auto* owner = createdLayout(result); + ensure("equal non-dispatchable descriptor values still represent two successful creates", owner && *owner); + ensure("the owner retains both equal descriptor values", + (*owner)->parameterSetLayout() == state.mDescriptorOutputs[0] && + (*owner)->sampledImageSetLayout() == state.mDescriptorOutputs[0]); + owner->reset(); + ensure_equals("each successful descriptor create has one destruction obligation", state.mDescriptorDestroyCount, std::size_t{ 2 }); + ensure("both equal handles are destroyed in reverse creation order", + state.mEvents[4].mDescriptorLayout == state.mDescriptorOutputs[0] && + state.mEvents[5].mDescriptorLayout == state.mDescriptorOutputs[0]); +} + +template<> +template<> +void render_vulkan_material_layout_test_object::test<8>() +{ + FakeState state; + const MaterialLayoutDevice device = fakeDevice(state); + auto result = createLegacyNormSpecPipelineLayout(device); + auto* owner = createdLayout(result); + ensure("the ownership fixture succeeds", owner && *owner); + + std::unique_ptr first = std::move(*owner); + std::unique_ptr transferred = std::move(first); + ensure("unique_ptr transfer changes the holder without moving the native owner", !first && transferred); + transferred.reset(); + ensure_equals("final release destroys the pipeline layout once", state.mPipelineDestroyCount, std::size_t{ 1 }); + ensure_equals("final release destroys both descriptor layouts", state.mDescriptorDestroyCount, std::size_t{ 2 }); + ensure("final destruction is pipeline, sampled set, parameter set", + state.mEvents[3].mKind == EventKind::DestroyPipelineLayout && state.mEvents[3].mPipelineLayout == state.mPipelineOutputs[0] && + state.mEvents[4].mKind == EventKind::DestroyDescriptorSetLayout && + state.mEvents[4].mDescriptorLayout == state.mDescriptorOutputs[1] && + state.mEvents[5].mKind == EventKind::DestroyDescriptorSetLayout && + state.mEvents[5].mDescriptorLayout == state.mDescriptorOutputs[0]); + ensure("destruction uses the creating device and null allocation callbacks", + state.mEvents[3].mDevice == device.mDevice && state.mEvents[4].mDevice == device.mDevice && + state.mEvents[5].mDevice == device.mDevice && state.mEvents[3].mAllocator == nullptr && + state.mEvents[4].mAllocator == nullptr && state.mEvents[5].mAllocator == nullptr); +} + +template<> +template<> +void render_vulkan_material_layout_test_object::test<9>() +{ + FakeState state; + state.mDescriptorOutputs[2] = state.mDescriptorOutputs[0]; + state.mDescriptorOutputs[3] = state.mDescriptorOutputs[1]; + state.mPipelineOutputs[1] = state.mPipelineOutputs[0]; + + const MaterialLayoutDevice device = fakeDevice(state); + auto first_result = createLegacyNormSpecPipelineLayout(device); + auto second_result = createLegacyNormSpecPipelineLayout(device); + auto* first_ptr = createdLayout(first_result); + auto* second_ptr = createdLayout(second_result); + ensure("both independent layout attempts succeed", first_ptr && *first_ptr && second_ptr && *second_ptr); + + auto first = std::move(*first_ptr); + auto second = std::move(*second_ptr); + ensure("independent attempts produce independent owners even when opaque values repeat", first.get() != second.get()); + ensure_equals("the factory has no hidden descriptor cache", state.mDescriptorCreateCount, std::size_t{ 4 }); + ensure_equals("the factory has no hidden pipeline cache", state.mPipelineCreateCount, std::size_t{ 2 }); + + second.reset(); + first.reset(); + ensure_equals("independent owners release both pipelines", state.mPipelineDestroyCount, std::size_t{ 2 }); + ensure_equals("independent owners release all descriptor layouts", state.mDescriptorDestroyCount, std::size_t{ 4 }); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendervulkanmaterialmodule_test.cpp b/indra/llrender/tests/llrendervulkanmaterialmodule_test.cpp new file mode 100644 index 00000000000..ff35ddee79b --- /dev/null +++ b/indra/llrender/tests/llrendervulkanmaterialmodule_test.cpp @@ -0,0 +1,453 @@ +/** + * @file llrendervulkanmaterialmodule_test.cpp + * @brief Tests for transactional Vulkan material shader modules. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendervulkanmaterialmodule.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; +using namespace LLRenderVulkanMaterial; + +constexpr std::uint32_t SPIRV_MAGIC = 0x07230203U; + +std::vector module(ShaderStage stage, std::uint32_t generator) +{ + // These minimal #version 450 modules were compiled for vulkan1.1 and pass + // spirv-val. They establish a valid fake-dispatch fixture, not packaged + // production-byte provenance or the material interface. + std::vector words; + if (stage == ShaderStage::Vertex) + { + words = { 0x07230203U, 0x00010300U, 0x0008000bU, 0x00000006U, 0x00000000U, 0x00020011U, 0x00000001U, 0x0006000bU, 0x00000001U, + 0x4c534c47U, 0x6474732eU, 0x3035342eU, 0x00000000U, 0x0003000eU, 0x00000000U, 0x00000001U, 0x0005000fU, 0x00000000U, + 0x00000004U, 0x6e69616dU, 0x00000000U, 0x00030003U, 0x00000002U, 0x000001c2U, 0x00040005U, 0x00000004U, 0x6e69616dU, + 0x00000000U, 0x00020013U, 0x00000002U, 0x00030021U, 0x00000003U, 0x00000002U, 0x00050036U, 0x00000002U, 0x00000004U, + 0x00000000U, 0x00000003U, 0x000200f8U, 0x00000005U, 0x000100fdU, 0x00010038U }; + } + else + { + words = { 0x07230203U, 0x00010300U, 0x0008000bU, 0x00000006U, 0x00000000U, 0x00020011U, 0x00000001U, 0x0006000bU, 0x00000001U, + 0x4c534c47U, 0x6474732eU, 0x3035342eU, 0x00000000U, 0x0003000eU, 0x00000000U, 0x00000001U, 0x0005000fU, 0x00000004U, + 0x00000004U, 0x6e69616dU, 0x00000000U, 0x00030010U, 0x00000004U, 0x00000007U, 0x00030003U, 0x00000002U, 0x000001c2U, + 0x00040005U, 0x00000004U, 0x6e69616dU, 0x00000000U, 0x00020013U, 0x00000002U, 0x00030021U, 0x00000003U, 0x00000002U, + 0x00050036U, 0x00000002U, 0x00000004U, 0x00000000U, 0x00000003U, 0x000200f8U, 0x00000005U, 0x000100fdU, 0x00010038U }; + } + words[2] = generator; + return words; +} + +LoadedShaderProgram structurallyAcceptedProgram(std::uint32_t generator = 1) +{ + return { legacyNormSpecModernHDRPipelineKey().mProgram, + { ShaderStage::Vertex, "main", module(ShaderStage::Vertex, generator) }, + { ShaderStage::Fragment, "main", module(ShaderStage::Fragment, generator) } }; +} + +ShaderGenerationLease acceptedLease(std::uint64_t frame = 1) +{ + return { { 1, 1 }, frame, std::make_shared(structurallyAcceptedProgram()) }; +} + +template +Handle fakeHandle(std::uintptr_t value) noexcept +{ + if constexpr (std::is_pointer_v) + { + return reinterpret_cast(value); + } + else + { + return static_cast(value); + } +} + +enum class EventKind : std::uint8_t +{ + Create, + Destroy +}; + +struct Event +{ + EventKind mKind = EventKind::Create; + VkDevice mDevice = VK_NULL_HANDLE; + VkShaderModule mModule = VK_NULL_HANDLE; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct CreateObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkStructureType mType = VK_STRUCTURE_TYPE_MAX_ENUM; + const void* mNext = nullptr; + VkShaderModuleCreateFlags mFlags = 0; + std::size_t mCodeSize = 0; + const std::uint32_t* mCode = nullptr; + std::uint32_t mFirstWord = 0; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct FakeState +{ + static constexpr std::size_t MAX_CALLS = 4; + + FakeState() + { + for (std::size_t index = 0; index < MAX_CALLS; ++index) + { + mOutputs[index] = fakeHandle(0x1001U + index); + } + } + + std::array mResults{ VK_SUCCESS, VK_SUCCESS, VK_SUCCESS, VK_SUCCESS }; + std::array mOutputs{}; + std::array mCreates{}; + std::array mEvents{}; + std::size_t mCreateCount = 0; + std::size_t mDestroyCount = 0; + std::size_t mEventCount = 0; +}; + +VKAPI_ATTR VkResult VKAPI_CALL fakeCreateShaderModule(VkDevice device, const VkShaderModuleCreateInfo* info, + const VkAllocationCallbacks* allocator, VkShaderModule* output) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || !info || !output || state->mCreateCount >= FakeState::MAX_CALLS) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + + const std::size_t index = state->mCreateCount++; + state->mCreates[index] = { device, + info->sType, + info->pNext, + info->flags, + info->codeSize, + info->pCode, + info->pCode && info->codeSize >= sizeof(std::uint32_t) ? info->pCode[0] : 0U, + allocator }; + *output = state->mOutputs[index]; + state->mEvents[state->mEventCount++] = { EventKind::Create, device, *output, allocator }; + return state->mResults[index]; +} + +VKAPI_ATTR void VKAPI_CALL fakeDestroyShaderModule(VkDevice device, VkShaderModule module, const VkAllocationCallbacks* allocator) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || state->mEventCount >= state->mEvents.size()) + { + return; + } + ++state->mDestroyCount; + state->mEvents[state->mEventCount++] = { EventKind::Destroy, device, module, allocator }; +} + +ShaderModuleDevice fakeDevice(FakeState& state) noexcept +{ + return { reinterpret_cast(&state), { fakeCreateShaderModule, fakeDestroyShaderModule } }; +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_material_module_test +{ +}; + +using render_vulkan_material_module_test_group = test_group; +using render_vulkan_material_module_test_object = render_vulkan_material_module_test_group::object; +render_vulkan_material_module_test_group render_vulkan_material_module_tests("render vulkan material module"); + +template<> +template<> +void render_vulkan_material_module_test_object::test<1>() +{ + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_constructible_v); + static_assert(!std::is_move_assignable_v); + static_assert(std::is_nothrow_destructible_v); + static_assert(noexcept( + createLegacyNormSpecShaderModules(std::declval(), std::declval()))); + static_assert(std::variant_size_v == 2); + static_assert(std::is_same_v, ShaderModuleCreationError>); + static_assert(std::is_same_v, std::unique_ptr>); +} + +template<> +template<> +void render_vulkan_material_module_test_object::test<2>() +{ + FakeState state; + const ShaderGenerationLease lease = acceptedLease(); + + auto result = createLegacyNormSpecShaderModules({}, lease); + const auto* error = std::get_if(&result); + ensure("a null device is rejected", error && error->mCode == ShaderModuleCreationCode::InvalidDevice); + + ShaderModuleDevice device = fakeDevice(state); + device.mDispatch.mCreateShaderModule = nullptr; + result = createLegacyNormSpecShaderModules(device, lease); + error = std::get_if(&result); + ensure("a missing create dispatch is rejected", error && error->mCode == ShaderModuleCreationCode::InvalidDispatch); + + device = fakeDevice(state); + device.mDispatch.mDestroyShaderModule = nullptr; + result = createLegacyNormSpecShaderModules(device, lease); + error = std::get_if(&result); + ensure("a missing destroy dispatch is rejected before creation", error && error->mCode == ShaderModuleCreationCode::InvalidDispatch); + ensure_equals("device and dispatch rejection makes no native call", state.mEventCount, std::size_t{ 0 }); +} + +template<> +template<> +void render_vulkan_material_module_test_object::test<3>() +{ + FakeState state; + const ShaderModuleDevice device = fakeDevice(state); + const ShaderGenerationLease valid = acceptedLease(); + + auto expect_invalid = [&](ShaderGenerationLease lease, const char* message) + { + auto result = createLegacyNormSpecShaderModules(device, lease); + const auto* error = std::get_if(&result); + ensure(message, error && error->mCode == ShaderModuleCreationCode::InvalidLease && !error->mStage && error->mResult == VK_SUCCESS); + }; + + ShaderGenerationLease lease = valid; + lease.mProgram.reset(); + expect_invalid(lease, "a null program is rejected"); + lease = valid; + lease.mHandle = {}; + expect_invalid(lease, "a zero handle is rejected"); + lease = valid; + lease.mHandle = { 2, 1 }; + expect_invalid(lease, "a noncanonical publication index is rejected"); + lease = valid; + lease.mFrame = 0; + expect_invalid(lease, "a zero frame is rejected"); + + LoadedShaderProgram malformed = *valid.mProgram; + malformed.mProgram.mName = "other.material"; + lease = valid; + lease.mProgram = std::make_shared(malformed); + expect_invalid(lease, "a wrong program is rejected"); + malformed = *valid.mProgram; + malformed.mVertex.mStage = ShaderStage::Fragment; + lease.mProgram = std::make_shared(malformed); + expect_invalid(lease, "a swapped stage is rejected"); + malformed = *valid.mProgram; + malformed.mFragment.mEntryPoint = "other"; + lease.mProgram = std::make_shared(malformed); + expect_invalid(lease, "a wrong entry point is rejected"); + malformed = *valid.mProgram; + malformed.mVertex.mWords[0] = 0; + lease.mProgram = std::make_shared(malformed); + expect_invalid(lease, "malformed SPIR-V is rejected"); + + ensure_equals("malformed leases make no native call", state.mEventCount, std::size_t{ 0 }); +} + +template<> +template<> +void render_vulkan_material_module_test_object::test<4>() +{ + FakeState state; + const ShaderModuleDevice device = fakeDevice(state); + const ShaderGenerationLease lease = acceptedLease(17); + auto result = createLegacyNormSpecShaderModules(device, lease); + auto* generation = std::get_if>(&result); + ensure("a canonical lease creates one owned module pair", generation && *generation); + + ensure_equals("the pair creates exactly two modules", state.mCreateCount, std::size_t{ 2 }); + ensure_equals("creation does not destroy live modules", state.mDestroyCount, std::size_t{ 0 }); + for (std::size_t index = 0; index < 2; ++index) + { + const CreateObservation& observed = state.mCreates[index]; + ensure("the creating device is exact", observed.mDevice == device.mDevice); + ensure("shader create info has the exact type", observed.mType == VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO); + ensure("shader create info has no extension chain", observed.mNext == nullptr); + ensure_equals("shader create flags are zero", observed.mFlags, VkShaderModuleCreateFlags{ 0 }); + ensure("allocation callbacks are null", observed.mAllocator == nullptr); + ensure_equals("the SPIR-V magic is forwarded", observed.mFirstWord, SPIRV_MAGIC); + } + ensure_equals("vertex byte count is exact", state.mCreates[0].mCodeSize, lease.mProgram->mVertex.mWords.size() * sizeof(std::uint32_t)); + ensure("the retained vertex word pointer is exact", state.mCreates[0].mCode == lease.mProgram->mVertex.mWords.data()); + ensure_equals("fragment byte count is exact", state.mCreates[1].mCodeSize, + lease.mProgram->mFragment.mWords.size() * sizeof(std::uint32_t)); + ensure("the retained fragment word pointer is exact", state.mCreates[1].mCode == lease.mProgram->mFragment.mWords.data()); + ensure("the owner preserves the logical handle", (*generation)->handle() == lease.mHandle); + ensure("the owner retains the exact immutable program", &(*generation)->program() == lease.mProgram.get()); + ensure("the owner exposes the exact vertex module", (*generation)->vertexModule() == state.mOutputs[0]); + ensure("the owner exposes the exact fragment module", (*generation)->fragmentModule() == state.mOutputs[1]); + ensure("native creation is vertex then fragment", + state.mEvents[0].mKind == EventKind::Create && state.mEvents[0].mModule == state.mOutputs[0] && + state.mEvents[1].mKind == EventKind::Create && state.mEvents[1].mModule == state.mOutputs[1]); + + std::unique_ptr first = std::move(*generation); + std::unique_ptr transferred = std::move(first); + ensure("unique_ptr transfer moves ownership without moving the native owner", !first && transferred); + transferred.reset(); + ensure_equals("final release destroys exactly two modules", state.mDestroyCount, std::size_t{ 2 }); + ensure("release destroys fragment then vertex", + state.mEvents[2].mKind == EventKind::Destroy && state.mEvents[2].mModule == state.mOutputs[1] && + state.mEvents[3].mKind == EventKind::Destroy && state.mEvents[3].mModule == state.mOutputs[0]); + ensure("destruction uses the creating device and null allocator", + state.mEvents[2].mDevice == device.mDevice && state.mEvents[3].mDevice == device.mDevice && + state.mEvents[2].mAllocator == nullptr && state.mEvents[3].mAllocator == nullptr); +} + +template<> +template<> +void render_vulkan_material_module_test_object::test<5>() +{ + FakeState state; + std::weak_ptr storage; + std::unique_ptr generation; + LegacyNormSpecShaderPublication publication; + const auto published = publication.publish(structurallyAcceptedProgram(71)); + ensure("the retention fixture publishes", published.has_value()); + auto lease = publication.resolveForFrame(*published, 9); + ensure("the retention fixture resolves", lease.has_value()); + storage = lease->mProgram; + + auto result = createLegacyNormSpecShaderModules(fakeDevice(state), *lease); + auto* result_ptr = std::get_if>(&result); + ensure("the retention fixture creates native ownership", result_ptr && *result_ptr); + generation = std::move(*result_ptr); + lease.reset(); + ensure("the retention fixture publishes a replacement", publication.publish(structurallyAcceptedProgram(72)).has_value()); + const auto retired = publication.completeThrough(9); + ensure("the source generation retires from publication ownership", + retired && retired->size() == 1 && retired->front().mHandle == *published); + + ensure("the native owner retains logically retired publication storage", generation && !storage.expired()); + ensure_equals("retained source remains readable", generation->program().mVertex.mWords[2], std::uint32_t{ 71 }); + generation.reset(); + ensure("source storage releases with the native owner", storage.expired()); +} + +template<> +template<> +void render_vulkan_material_module_test_object::test<6>() +{ + FakeState state; + state.mResults[0] = VK_ERROR_OUT_OF_HOST_MEMORY; + state.mOutputs[0] = fakeHandle(0x2001U); + + auto result = createLegacyNormSpecShaderModules(fakeDevice(state), acceptedLease()); + const auto* error = std::get_if(&result); + ensure("a vertex failure preserves its stage and result", + error && error->mCode == ShaderModuleCreationCode::CreateFailure && error->mStage == ShaderStage::Vertex && + error->mResult == VK_ERROR_OUT_OF_HOST_MEMORY); + ensure_equals("a vertex failure stops after one create", state.mCreateCount, std::size_t{ 1 }); + ensure_equals("a handle written with a failed result is not treated as owned", state.mDestroyCount, std::size_t{ 0 }); + + FakeState null_state; + null_state.mOutputs[0] = VK_NULL_HANDLE; + result = createLegacyNormSpecShaderModules(fakeDevice(null_state), acceptedLease()); + error = std::get_if(&result); + ensure("success with a null vertex fails closed", + error && error->mCode == ShaderModuleCreationCode::NullModule && error->mStage == ShaderStage::Vertex && + error->mResult == VK_SUCCESS); + ensure_equals("a null vertex has nothing to destroy", null_state.mDestroyCount, std::size_t{ 0 }); +} + +template<> +template<> +void render_vulkan_material_module_test_object::test<7>() +{ + FakeState state; + state.mResults[1] = VK_ERROR_OUT_OF_DEVICE_MEMORY; + state.mOutputs[1] = state.mOutputs[0]; + + auto result = createLegacyNormSpecShaderModules(fakeDevice(state), acceptedLease()); + const auto* error = std::get_if(&result); + ensure("a fragment failure preserves its stage and result", + error && error->mCode == ShaderModuleCreationCode::CreateFailure && error->mStage == ShaderStage::Fragment && + error->mResult == VK_ERROR_OUT_OF_DEVICE_MEMORY); + ensure_equals("a fragment failure attempts both stages", state.mCreateCount, std::size_t{ 2 }); + ensure_equals("fragment failure rolls back only the owned vertex", state.mDestroyCount, std::size_t{ 1 }); + ensure("fragment failure rollback follows both create attempts", + state.mEvents[2].mKind == EventKind::Destroy && state.mEvents[2].mModule == state.mOutputs[0]); + + FakeState null_state; + null_state.mOutputs[1] = VK_NULL_HANDLE; + result = createLegacyNormSpecShaderModules(fakeDevice(null_state), acceptedLease()); + error = std::get_if(&result); + ensure("success with a null fragment fails closed", + error && error->mCode == ShaderModuleCreationCode::NullModule && error->mStage == ShaderStage::Fragment); + ensure_equals("a null fragment rolls back the vertex", null_state.mDestroyCount, std::size_t{ 1 }); + ensure("null fragment rollback destroys the exact vertex", null_state.mEvents[2].mModule == null_state.mOutputs[0]); +} + +template<> +template<> +void render_vulkan_material_module_test_object::test<8>() +{ + FakeState state; + state.mOutputs[1] = state.mOutputs[0]; + + auto result = createLegacyNormSpecShaderModules(fakeDevice(state), acceptedLease()); + auto* generation = std::get_if>(&result); + ensure("equal non-dispatchable handle values still represent two successful creates", generation && *generation); + ensure("the owner retains both equal handle values", + (*generation)->vertexModule() == state.mOutputs[0] && (*generation)->fragmentModule() == state.mOutputs[0]); + generation->reset(); + ensure_equals("each successful create has one destruction obligation", state.mDestroyCount, std::size_t{ 2 }); + ensure("equal handles are destroyed in reverse creation order", + state.mEvents[2].mModule == state.mOutputs[0] && state.mEvents[3].mModule == state.mOutputs[0]); +} + +template<> +template<> +void render_vulkan_material_module_test_object::test<9>() +{ + FakeState state; + const ShaderModuleDevice device = fakeDevice(state); + const ShaderGenerationLease lease = acceptedLease(23); + + auto first_result = createLegacyNormSpecShaderModules(device, lease); + auto second_result = createLegacyNormSpecShaderModules(device, lease); + auto* first_ptr = std::get_if>(&first_result); + auto* second_ptr = std::get_if>(&second_result); + ensure("both independent creation attempts succeed", first_ptr && *first_ptr && second_ptr && *second_ptr); + auto first = std::move(*first_ptr); + auto second = std::move(*second_ptr); + ensure("the same valid lease creates independent owners", first && second && first.get() != second.get()); + ensure_equals("the factory has no hidden generation cache", state.mCreateCount, std::size_t{ 4 }); + ensure("the pairs own four distinct native handles", + first->vertexModule() != second->vertexModule() && first->fragmentModule() != second->fragmentModule()); + + second.reset(); + first.reset(); + ensure_equals("independent owners release all four handles", state.mDestroyCount, std::size_t{ 4 }); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendervulkanmaterialpipelinecache_test.cpp b/indra/llrender/tests/llrendervulkanmaterialpipelinecache_test.cpp new file mode 100644 index 00000000000..6534a15ac75 --- /dev/null +++ b/indra/llrender/tests/llrendervulkanmaterialpipelinecache_test.cpp @@ -0,0 +1,278 @@ +/** + * @file llrendervulkanmaterialpipelinecache_test.cpp + * @brief Tests for transactional Vulkan material pipeline-cache ownership. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendervulkanmaterialpipelinecache.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderVulkanMaterial; + +template +Handle fakeHandle(std::uintptr_t value) noexcept +{ + if constexpr (std::is_pointer_v) + { + return reinterpret_cast(value); + } + else + { + return static_cast(value); + } +} + +struct PipelineCacheCreateObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkStructureType mType = VK_STRUCTURE_TYPE_MAX_ENUM; + const void* mNext = nullptr; + VkPipelineCacheCreateFlags mFlags = 0; + std::size_t mInitialDataSize = 0; + const void* mInitialData = nullptr; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct PipelineCacheDestroyObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkPipelineCache mPipelineCache = VK_NULL_HANDLE; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct PipelineCacheState +{ + static constexpr std::size_t MAX_CALLS = 4; + + PipelineCacheState() + { + for (std::size_t index = 0; index < MAX_CALLS; ++index) + { + mOutputs[index] = fakeHandle(0x7001U + index); + } + } + + std::array mResults{}; + std::array mOutputs{}; + std::array mCreates{}; + std::array mDestroys{}; + std::size_t mCreateCount = 0; + std::size_t mDestroyCount = 0; +}; + +VKAPI_ATTR VkResult VKAPI_CALL fakeCreatePipelineCache(VkDevice device, const VkPipelineCacheCreateInfo* info, + const VkAllocationCallbacks* allocator, VkPipelineCache* output) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || !info || !output || state->mCreateCount >= PipelineCacheState::MAX_CALLS) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + + const std::size_t index = state->mCreateCount++; + state->mCreates[index] = { device, info->sType, info->pNext, info->flags, info->initialDataSize, info->pInitialData, allocator }; + *output = state->mOutputs[index]; + return state->mResults[index]; +} + +VKAPI_ATTR void VKAPI_CALL fakeDestroyPipelineCache(VkDevice device, VkPipelineCache pipeline_cache, + const VkAllocationCallbacks* allocator) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || state->mDestroyCount >= PipelineCacheState::MAX_CALLS) + { + return; + } + + state->mDestroys[state->mDestroyCount++] = { device, pipeline_cache, allocator }; +} + +MaterialPipelineCacheDevice fakeDevice(PipelineCacheState& state) noexcept +{ + return { reinterpret_cast(&state), { fakeCreatePipelineCache, fakeDestroyPipelineCache } }; +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_material_pipeline_cache_test +{ +}; + +using render_vulkan_material_pipeline_cache_test_group = test_group; +using render_vulkan_material_pipeline_cache_test_object = render_vulkan_material_pipeline_cache_test_group::object; +render_vulkan_material_pipeline_cache_test_group render_vulkan_material_pipeline_cache_tests("render Vulkan material pipeline cache"); + +template<> +template<> +void render_vulkan_material_pipeline_cache_test_object::test<1>() +{ + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_constructible_v); + static_assert(!std::is_move_assignable_v); + static_assert(std::is_nothrow_destructible_v); + static_assert(noexcept(createMaterialPipelineCache(std::declval()))); + static_assert(std::variant_size_v == 2); + static_assert(std::is_same_v, MaterialPipelineCacheCreationError>); + static_assert( + std::is_same_v, std::unique_ptr>); + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(std::is_nothrow_move_constructible_v); + static_assert(std::is_nothrow_move_assignable_v); + static_assert(std::is_nothrow_destructible_v); + + const MaterialPipelineCacheCreationError left{ MaterialPipelineCacheCreationCode::CreateFailure, VK_ERROR_DEVICE_LOST }; + const MaterialPipelineCacheCreationError same{ MaterialPipelineCacheCreationCode::CreateFailure, VK_ERROR_DEVICE_LOST }; + const MaterialPipelineCacheCreationError different{ MaterialPipelineCacheCreationCode::NullPipelineCache, VK_SUCCESS }; + ensure("equal errors compare equal", left == same); + ensure("different errors compare unequal", !(left == different)); +} + +template<> +template<> +void render_vulkan_material_pipeline_cache_test_object::test<2>() +{ + PipelineCacheState state; + + auto result = createMaterialPipelineCache({}); + const auto* error = std::get_if(&result); + ensure("a null device is rejected", + error && error->mCode == MaterialPipelineCacheCreationCode::InvalidDevice && error->mResult == VK_SUCCESS); + + MaterialPipelineCacheDevice device = fakeDevice(state); + device.mDispatch.mCreatePipelineCache = nullptr; + result = createMaterialPipelineCache(device); + error = std::get_if(&result); + ensure("a missing create callback is rejected", + error && error->mCode == MaterialPipelineCacheCreationCode::InvalidDispatch && error->mResult == VK_SUCCESS); + + device = fakeDevice(state); + device.mDispatch.mDestroyPipelineCache = nullptr; + result = createMaterialPipelineCache(device); + error = std::get_if(&result); + ensure("a missing destroy callback is rejected", + error && error->mCode == MaterialPipelineCacheCreationCode::InvalidDispatch && error->mResult == VK_SUCCESS); + ensure("preflight failures make no native calls", state.mCreateCount == 0 && state.mDestroyCount == 0); +} + +template<> +template<> +void render_vulkan_material_pipeline_cache_test_object::test<3>() +{ + PipelineCacheState state; + MaterialPipelineCacheDevice device = fakeDevice(state); + PipelineCacheState other_state; + + { + auto result = createMaterialPipelineCache(device); + auto* owner = std::get_if>(&result); + ensure("cold creation returns an owner", owner && *owner); + ensure("the cache handle is a stable borrow", + (*owner)->pipelineCache() == state.mOutputs[0] && (*owner)->pipelineCache() == (*owner)->pipelineCache()); + ensure("the creating device is retained", (*owner)->createdOn(device.mDevice) && + !(*owner)->createdOn(reinterpret_cast(&other_state)) && + !(*owner)->createdOn(VK_NULL_HANDLE)); + + const PipelineCacheCreateObservation& create = state.mCreates[0]; + ensure("the cold cache create call is exact", + state.mCreateCount == 1 && create.mDevice == device.mDevice && + create.mType == VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO && create.mNext == nullptr && create.mFlags == 0 && + create.mInitialDataSize == 0 && create.mInitialData == nullptr && create.mAllocator == nullptr); + ensure("a live owner has not destroyed its cache", state.mDestroyCount == 0); + } + + ensure("destruction releases the exact cache once", + state.mDestroyCount == 1 && state.mDestroys[0].mDevice == device.mDevice && + state.mDestroys[0].mPipelineCache == state.mOutputs[0] && state.mDestroys[0].mAllocator == nullptr); +} + +template<> +template<> +void render_vulkan_material_pipeline_cache_test_object::test<4>() +{ + PipelineCacheState state; + state.mResults[0] = VK_ERROR_OUT_OF_HOST_MEMORY; + ensure("the poisoned failure output is non-null", state.mOutputs[0] != VK_NULL_HANDLE); + + auto result = createMaterialPipelineCache(fakeDevice(state)); + const auto* error = std::get_if(&result); + ensure("native failure is preserved", + error && error->mCode == MaterialPipelineCacheCreationCode::CreateFailure && error->mResult == VK_ERROR_OUT_OF_HOST_MEMORY); + ensure("an output written on failure is not owned or destroyed", state.mCreateCount == 1 && state.mDestroyCount == 0); +} + +template<> +template<> +void render_vulkan_material_pipeline_cache_test_object::test<5>() +{ + PipelineCacheState state; + state.mOutputs[0] = VK_NULL_HANDLE; + + auto result = createMaterialPipelineCache(fakeDevice(state)); + const auto* error = std::get_if(&result); + ensure("success with a null cache is rejected", + error && error->mCode == MaterialPipelineCacheCreationCode::NullPipelineCache && error->mResult == VK_SUCCESS); + ensure("a null cache is never destroyed", state.mCreateCount == 1 && state.mDestroyCount == 0); +} + +template<> +template<> +void render_vulkan_material_pipeline_cache_test_object::test<6>() +{ + PipelineCacheState first_state; + PipelineCacheState second_state; + const VkPipelineCache shared_opaque_value = fakeHandle(0x7fffU); + first_state.mOutputs[0] = shared_opaque_value; + second_state.mOutputs[0] = shared_opaque_value; + + const MaterialPipelineCacheDevice first_device = fakeDevice(first_state); + const MaterialPipelineCacheDevice second_device = fakeDevice(second_state); + auto first_result = createMaterialPipelineCache(first_device); + auto second_result = createMaterialPipelineCache(second_device); + auto* first_owner = std::get_if>(&first_result); + auto* second_owner = std::get_if>(&second_result); + + ensure("independent owners may borrow equal opaque values", + first_owner && *first_owner && second_owner && *second_owner && (*first_owner)->pipelineCache() == shared_opaque_value && + (*second_owner)->pipelineCache() == shared_opaque_value && (*first_owner)->createdOn(first_device.mDevice) && + !(*first_owner)->createdOn(second_device.mDevice) && (*second_owner)->createdOn(second_device.mDevice) && + !(*second_owner)->createdOn(first_device.mDevice)); + + first_owner->reset(); + ensure("destroying the first owner does not retire the second", + first_state.mDestroyCount == 1 && second_state.mDestroyCount == 0 && (*second_owner)->pipelineCache() == shared_opaque_value); + second_owner->reset(); + ensure("each owner destroys its device-local cache", + second_state.mDestroyCount == 1 && first_state.mDestroys[0].mDevice == first_device.mDevice && + first_state.mDestroys[0].mPipelineCache == shared_opaque_value && + second_state.mDestroys[0].mDevice == second_device.mDevice && + second_state.mDestroys[0].mPipelineCache == shared_opaque_value && first_state.mDestroys[0].mAllocator == nullptr && + second_state.mDestroys[0].mAllocator == nullptr); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendervulkanmaterialregistry_test.cpp b/indra/llrender/tests/llrendervulkanmaterialregistry_test.cpp new file mode 100644 index 00000000000..b675a0ff2f7 --- /dev/null +++ b/indra/llrender/tests/llrendervulkanmaterialregistry_test.cpp @@ -0,0 +1,231 @@ +/** + * @file llrendervulkanmaterialregistry_test.cpp + * @brief Focused tests for the Vulkan material registry and frozen index mapping. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendervulkanmaterial.h" +#include "lltut.h" + +#include +#include +#include +#include + +namespace +{ + +template +Handle fakeHandle(std::uintptr_t value) +{ + if constexpr (std::is_pointer_v) + { + return reinterpret_cast(value); + } + else + { + return static_cast(value); + } +} + +LLRenderVulkanMaterial::ImageBinding completeImageBinding() +{ + LLRenderVulkanMaterial::ImageBinding image; + image.mImage = fakeHandle(0x20); + image.mView = fakeHandle(0x21); + image.mFormat = VK_FORMAT_R8G8B8A8_UNORM; + image.mExtent = { 8, 8 }; + image.mMipLevels = 1; + image.mUsage = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; + image.mAspect = VK_IMAGE_ASPECT_COLOR_BIT; + image.mLayout = VK_IMAGE_LAYOUT_UNDEFINED; + image.mViewRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, 1, 0, 1 }; + return image; +} + +LLRenderVulkanMaterial::PipelineBinding completePipelineBinding() +{ + using namespace LLRenderVulkanMaterial; + + static std::array mapped_parameters{}; + + PipelineBinding pipeline; + pipeline.mProgram = { "deferred.material.normspec", 0 }; + pipeline.mPipeline = fakeHandle(0x40); + pipeline.mLayout = fakeHandle(0x41); + pipeline.mRenderPass = fakeHandle(0x42); + pipeline.mFramebuffer = fakeHandle(0x43); + pipeline.mDescriptorSets = { fakeHandle(0x44), + fakeHandle(0x45) }; + pipeline.mParameters.mBuffer = fakeHandle(0x46); + pipeline.mParameters.mSize = mapped_parameters.size(); + pipeline.mParameters.mUsage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + pipeline.mParameters.mMemory = fakeHandle(0x47); + pipeline.mParameters.mMapped = mapped_parameters.data(); + pipeline.mParameters.mAllocationSize = mapped_parameters.size(); + pipeline.mParameters.mDescriptorRange = mapped_parameters.size(); + pipeline.mVertexShaderIdentity[0] = 0x5a; + pipeline.mFragmentShaderIdentity[0] = 0xa5; + return pipeline; +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_material_registry_test +{ +}; + +using render_vulkan_material_registry_group = test_group; +using render_vulkan_material_registry_object = render_vulkan_material_registry_group::object; +render_vulkan_material_registry_group render_vulkan_material_registry_tests("render Vulkan material registry"); + +template<> +template<> +void render_vulkan_material_registry_object::test<1>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanMaterial; + + Registry registry; + const BufferHandle buffer_handle{ 1, 4 }; + const ImageHandle image_handle{ 1, 5 }; + const SamplerHandle sampler_handle{ 1, 6 }; + const PipelineHandle pipeline_handle{ 1, 7 }; + + BufferBinding buffer; + buffer.mBuffer = fakeHandle(0x10); + buffer.mSize = MATERIAL_INDEX_BUFFER_SIZE; + buffer.mUsage = VK_BUFFER_USAGE_INDEX_BUFFER_BIT; + buffer.mHasTranslatedIndices = true; + buffer.mTranslatedIndices = MATERIAL_VULKAN_INDICES; + const ImageBinding image = completeImageBinding(); + SamplerBinding sampler; + sampler.mSampler = fakeHandle(0x30); + const PipelineBinding pipeline = completePipelineBinding(); + + ensure("buffer registers", registry.addBuffer(buffer_handle, buffer)); + ensure("image registers", registry.addImage(image_handle, image)); + ensure("sampler registers", registry.addSampler(sampler_handle, sampler)); + ensure("pipeline registers", registry.addPipeline(pipeline_handle, pipeline)); + ensure("exact buffer resolves", registry.resolve(buffer_handle) != nullptr); + ensure("exact image resolves", registry.resolve(image_handle) != nullptr); + ensure("exact sampler resolves", registry.resolve(sampler_handle) != nullptr); + ensure("exact pipeline and program resolve", + registry.resolve(pipeline_handle, { "deferred.material.normspec", 0 }) != nullptr); + ensure("translated indices are retained", + registry.resolve(buffer_handle)->mTranslatedIndices == MATERIAL_VULKAN_INDICES); + ensure("vertex shader identity is retained", + registry.resolve(pipeline_handle, pipeline.mProgram)->mVertexShaderIdentity == + pipeline.mVertexShaderIdentity); +} + +template<> +template<> +void render_vulkan_material_registry_object::test<2>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanMaterial; + + Registry registry; + const ImageBinding image = completeImageBinding(); + const PipelineBinding pipeline = completePipelineBinding(); + + ensure("image registers", registry.addImage({ 2, 7 }, image)); + ensure("pipeline registers", registry.addPipeline({ 4, 9 }, pipeline)); + ensure("stale image generation is rejected", registry.resolve(ImageHandle{ 2, 6 }) == nullptr); + ensure("unknown image index is rejected", registry.resolve(ImageHandle{ 8, 7 }) == nullptr); + ensure("program variant mismatch is rejected", + registry.resolve(PipelineHandle{ 4, 9 }, { "deferred.material.normspec", 1 }) == nullptr); + ensure("program name mismatch is rejected", + registry.resolve(PipelineHandle{ 4, 9 }, { "other.material", 0 }) == nullptr); + ensure("stale pipeline generation is rejected", + registry.resolve(PipelineHandle{ 4, 8 }, pipeline.mProgram) == nullptr); + ensure("duplicate live image index is rejected", !registry.addImage({ 2, 8 }, image)); + + ImageBinding incomplete_image = image; + incomplete_image.mView = VK_NULL_HANDLE; + ensure("incomplete image is rejected", !registry.addImage({ 9, 1 }, incomplete_image)); + PipelineBinding incomplete_pipeline = pipeline; + incomplete_pipeline.mVertexShaderIdentity = {}; + ensure("pipeline without immutable shader identity is rejected", + !registry.addPipeline({ 9, 1 }, incomplete_pipeline)); + + std::array equal_descriptor_sets{ + pipeline.mDescriptorSets[0], pipeline.mDescriptorSets[0] + }; + ensure("equal non-null opaque descriptor set values form a valid pair", + validMaterialDescriptorSetPair(equal_descriptor_sets)); + equal_descriptor_sets[0] = VK_NULL_HANDLE; + ensure("a null parameter set is rejected", + !validMaterialDescriptorSetPair(equal_descriptor_sets)); + equal_descriptor_sets = { pipeline.mDescriptorSets[0], VK_NULL_HANDLE }; + ensure("a null sampled-image set is rejected", + !validMaterialDescriptorSetPair(equal_descriptor_sets)); +} + +template<> +template<> +void render_vulkan_material_registry_object::test<3>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanMaterial; + + ensure_equals("parameter packet remains exactly 272 bytes", + MATERIAL_PARAMETER_SIZE, static_cast(272)); + for (std::size_t triangle = 0; triangle < MATERIAL_INDICES.size(); triangle += 3) + { + ensure_equals("Vulkan first vertex equals GL last vertex", + MATERIAL_VULKAN_INDICES[triangle], MATERIAL_INDICES[triangle + 2]); + ensure_equals("cyclic rotation retains canonical first vertex", + MATERIAL_VULKAN_INDICES[triangle + 1], MATERIAL_INDICES[triangle]); + ensure_equals("cyclic rotation retains canonical second vertex", + MATERIAL_VULKAN_INDICES[triangle + 2], MATERIAL_INDICES[triangle + 1]); + } +} + +template<> +template<> +void render_vulkan_material_registry_object::test<4>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanMaterial; + + MaterialInputs inputs; + inputs.mFrame = 1; + const auto frame = buildMaterialFrame(inputs); + ensure("canonical material frame builds", frame.has_value()); + + Registry registry; + std::uint64_t recording_attempts = 0; + std::uint64_t submissions = 0; + ExecutionContext context; + context.mDevice = fakeHandle(0x100); + context.mCommandBuffer = fakeHandle(0x101); + context.mQueue = fakeHandle(0x102); + context.mRecordingAttemptCount = &recording_attempts; + context.mSubmissionCount = &submissions; + context.mRequiredVertexShaderIdentity[0] = 1; + context.mRequiredFragmentShaderIdentity[0] = 2; + + std::string error; + ensure("unresolved registry is rejected", !execute(*frame, registry, context, error)); + ensure("preflight rejection explains failure", !error.empty()); + ensure_equals("preflight performs no recording attempt", recording_attempts, std::uint64_t{ 0 }); + ensure_equals("preflight performs no submission", submissions, std::uint64_t{ 0 }); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendervulkanmaterialrenderpass_test.cpp b/indra/llrender/tests/llrendervulkanmaterialrenderpass_test.cpp new file mode 100644 index 00000000000..267abadee14 --- /dev/null +++ b/indra/llrender/tests/llrendervulkanmaterialrenderpass_test.cpp @@ -0,0 +1,532 @@ +/** + * @file llrendervulkanmaterialrenderpass_test.cpp + * @brief Tests for transactional Vulkan material render-pass ownership. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendervulkanmaterialrenderpass.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; +using namespace LLRenderVulkanMaterial; + +template +Handle fakeHandle(std::uintptr_t value) noexcept +{ + if constexpr (std::is_pointer_v) + { + return reinterpret_cast(value); + } + else + { + return static_cast(value); + } +} + +struct ProfileState +{ + ProfileState() + { + mFeatures.independentBlend = VK_TRUE; + mProperties.limits.maxColorAttachments = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + mProperties.limits.maxFragmentOutputAttachments = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + for (std::size_t index = 0; index < LEGACY_NORMSPEC_RENDER_PASS_ATTACHMENT_COUNT; ++index) + { + mFormats[index].optimalTilingFeatures = + index < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT + ? VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT + : VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT; + mImages[index].maxExtent = { 4096, 4096, 1 }; + mImages[index].maxMipLevels = 1; + mImages[index].maxArrayLayers = 1; + mImages[index].sampleCounts = VK_SAMPLE_COUNT_1_BIT; + mImages[index].maxResourceSize = 1ULL << 30; + } + } + + VkPhysicalDeviceFeatures mFeatures{}; + VkPhysicalDeviceProperties mProperties{}; + std::array mFormats{}; + std::array mImages{}; + std::size_t mFeaturesCount = 0; + std::size_t mFormatCount = 0; + std::size_t mImageCount = 0; +}; + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceFeatures(VkPhysicalDevice physical_device, VkPhysicalDeviceFeatures* features) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (state && features) + { + ++state->mFeaturesCount; + *features = state->mFeatures; + } +} + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceProperties(VkPhysicalDevice physical_device, + VkPhysicalDeviceProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (state && properties) + { + *properties = state->mProperties; + } +} + +VKAPI_ATTR void VKAPI_CALL fakeGetPhysicalDeviceFormatProperties(VkPhysicalDevice physical_device, VkFormat, + VkFormatProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !properties || state->mFormatCount >= state->mFormats.size()) + { + return; + } + *properties = state->mFormats[state->mFormatCount++]; +} + +VKAPI_ATTR VkResult VKAPI_CALL fakeGetPhysicalDeviceImageFormatProperties(VkPhysicalDevice physical_device, VkFormat, VkImageType, + VkImageTiling, VkImageUsageFlags, VkImageCreateFlags, + VkImageFormatProperties* properties) noexcept +{ + auto* state = reinterpret_cast(physical_device); + if (!state || !properties || state->mImageCount >= state->mImages.size()) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + *properties = state->mImages[state->mImageCount++]; + return VK_SUCCESS; +} + +MaterialAttachmentResolutionResult resolveProfile(ProfileState& state, const LegacyNormSpecPipelineKey& key) noexcept +{ + MaterialAttachmentDevice device{ reinterpret_cast(&state), + { fakeGetPhysicalDeviceFeatures, fakeGetPhysicalDeviceProperties, + fakeGetPhysicalDeviceFormatProperties, fakeGetPhysicalDeviceImageFormatProperties } }; + return resolveLegacyNormSpecAttachmentProfile(device, key); +} + +struct RenderPassCreateObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkStructureType mType = VK_STRUCTURE_TYPE_MAX_ENUM; + const void* mNext = nullptr; + VkRenderPassCreateFlags mFlags = 0; + std::uint32_t mAttachmentCount = 0; + std::array mAttachments{}; + std::uint32_t mSubpassCount = 0; + VkSubpassDescriptionFlags mSubpassFlags = 0; + VkPipelineBindPoint mBindPoint = VK_PIPELINE_BIND_POINT_MAX_ENUM; + std::uint32_t mInputCount = 0; + bool mHasInputPointer = false; + std::uint32_t mColorCount = 0; + std::array mColorReferences{}; + bool mHasResolvePointer = false; + bool mHasDepthReference = false; + VkAttachmentReference mDepthReference{}; + std::uint32_t mPreserveCount = 0; + bool mHasPreservePointer = false; + std::uint32_t mDependencyCount = 0; + bool mHasDependencyPointer = false; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct RenderPassDestroyObservation +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkRenderPass mRenderPass = VK_NULL_HANDLE; + const VkAllocationCallbacks* mAllocator = nullptr; +}; + +struct RenderPassState +{ + static constexpr std::size_t MAX_CALLS = 4; + + RenderPassState() + { + for (std::size_t index = 0; index < MAX_CALLS; ++index) + { + mOutputs[index] = fakeHandle(0x3001U + index); + } + } + + std::array mResults{}; + std::array mOutputs{}; + std::array mCreates{}; + std::array mDestroys{}; + std::size_t mCreateCount = 0; + std::size_t mDestroyCount = 0; +}; + +VKAPI_ATTR VkResult VKAPI_CALL fakeCreateRenderPass(VkDevice device, const VkRenderPassCreateInfo* info, + const VkAllocationCallbacks* allocator, VkRenderPass* output) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || !info || !output || state->mCreateCount >= RenderPassState::MAX_CALLS || info->attachmentCount > 5 || + (info->attachmentCount != 0 && !info->pAttachments) || info->subpassCount > 1 || (info->subpassCount != 0 && !info->pSubpasses)) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + + const std::size_t index = state->mCreateCount++; + auto& observation = state->mCreates[index]; + observation.mDevice = device; + observation.mType = info->sType; + observation.mNext = info->pNext; + observation.mFlags = info->flags; + observation.mAttachmentCount = info->attachmentCount; + observation.mSubpassCount = info->subpassCount; + observation.mDependencyCount = info->dependencyCount; + observation.mHasDependencyPointer = info->pDependencies != nullptr; + observation.mAllocator = allocator; + for (std::uint32_t attachment = 0; attachment < info->attachmentCount; ++attachment) + { + observation.mAttachments[attachment] = info->pAttachments[attachment]; + } + + if (info->subpassCount == 1) + { + const VkSubpassDescription& subpass = info->pSubpasses[0]; + if (subpass.colorAttachmentCount > LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT || + (subpass.colorAttachmentCount != 0 && !subpass.pColorAttachments)) + { + return VK_ERROR_INITIALIZATION_FAILED; + } + observation.mSubpassFlags = subpass.flags; + observation.mBindPoint = subpass.pipelineBindPoint; + observation.mInputCount = subpass.inputAttachmentCount; + observation.mHasInputPointer = subpass.pInputAttachments != nullptr; + observation.mColorCount = subpass.colorAttachmentCount; + observation.mHasResolvePointer = subpass.pResolveAttachments != nullptr; + observation.mHasDepthReference = subpass.pDepthStencilAttachment != nullptr; + observation.mPreserveCount = subpass.preserveAttachmentCount; + observation.mHasPreservePointer = subpass.pPreserveAttachments != nullptr; + for (std::uint32_t color = 0; color < subpass.colorAttachmentCount; ++color) + { + observation.mColorReferences[color] = subpass.pColorAttachments[color]; + } + if (subpass.pDepthStencilAttachment) + { + observation.mDepthReference = *subpass.pDepthStencilAttachment; + } + } + + *output = state->mOutputs[index]; + return state->mResults[index]; +} + +VKAPI_ATTR void VKAPI_CALL fakeDestroyRenderPass(VkDevice device, VkRenderPass render_pass, const VkAllocationCallbacks* allocator) noexcept +{ + auto* state = reinterpret_cast(device); + if (!state || state->mDestroyCount >= state->mDestroys.size()) + { + return; + } + state->mDestroys[state->mDestroyCount++] = { device, render_pass, allocator }; +} + +MaterialRenderPassDevice fakeDevice(ProfileState& profile_state, RenderPassState& render_state) noexcept +{ + // This fake proves host-side create-info and ownership behavior only. It + // cannot establish the public graphics-queue-family precondition. + return { reinterpret_cast(&profile_state), + reinterpret_cast(&render_state), + { fakeCreateRenderPass, fakeDestroyRenderPass } }; +} + +const MaterialRenderPassCreationError* creationError(const MaterialRenderPassCreationResult& result) noexcept +{ + return std::get_if(&result); +} + +LegacyNormSpecRenderPass* createdOwner(MaterialRenderPassCreationResult& result) noexcept +{ + auto* owner = std::get_if>(&result); + return owner ? owner->get() : nullptr; +} + +const LegacyNormSpecAttachmentProfile* resolvedProfile(const MaterialAttachmentResolutionResult& result) noexcept +{ + return std::get_if(&result); +} + +void ensureCreationError(const char* message, const MaterialRenderPassCreationResult& result, MaterialRenderPassCreationCode code, + VkResult native_result = VK_SUCCESS) +{ + const auto* error = creationError(result); + tut::ensure(message, error && error->mCode == code && error->mResult == native_result); +} + +void ensureExactCreateInfo(const RenderPassCreateObservation& observation, const LegacyNormSpecAttachmentProfile& profile) +{ + tut::ensure("the top-level create info is exact", + observation.mType == VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO && !observation.mNext && observation.mFlags == 0 && + observation.mAttachmentCount == LEGACY_NORMSPEC_RENDER_PASS_ATTACHMENT_COUNT && observation.mSubpassCount == 1 && + observation.mDependencyCount == 0 && !observation.mHasDependencyPointer && !observation.mAllocator); + + for (std::size_t slot = 0; slot < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; ++slot) + { + const VkAttachmentDescription& attachment = observation.mAttachments[slot]; + tut::ensure("every color description consumes the resolved profile", + attachment.flags == 0 && attachment.format == profile.colors()[slot].mNativeFormat && + attachment.samples == VK_SAMPLE_COUNT_1_BIT && attachment.loadOp == profile.colors()[slot].mRequiredLoadOp && + attachment.storeOp == VK_ATTACHMENT_STORE_OP_STORE && attachment.stencilLoadOp == VK_ATTACHMENT_LOAD_OP_DONT_CARE && + attachment.stencilStoreOp == VK_ATTACHMENT_STORE_OP_DONT_CARE && + attachment.initialLayout == LegacyNormSpecRenderPass::colorAttachmentLayout() && + attachment.finalLayout == LegacyNormSpecRenderPass::colorAttachmentLayout()); + tut::ensure("every color output has its ordered attachment reference", + observation.mColorReferences[slot].attachment == slot && + observation.mColorReferences[slot].layout == LegacyNormSpecRenderPass::colorAttachmentLayout()); + } + + const VkAttachmentDescription& depth = observation.mAttachments[LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT]; + tut::ensure("the depth description consumes the resolved profile", + depth.flags == 0 && depth.format == profile.depth().mNativeFormat && depth.samples == VK_SAMPLE_COUNT_1_BIT && + depth.loadOp == profile.depth().mRequiredLoadOp && depth.storeOp == VK_ATTACHMENT_STORE_OP_STORE && + depth.stencilLoadOp == VK_ATTACHMENT_LOAD_OP_DONT_CARE && depth.stencilStoreOp == VK_ATTACHMENT_STORE_OP_DONT_CARE && + depth.initialLayout == LegacyNormSpecRenderPass::depthAttachmentLayout() && + depth.finalLayout == LegacyNormSpecRenderPass::depthAttachmentLayout()); + tut::ensure("one graphics subpass contains only four colors and depth", + observation.mSubpassFlags == 0 && observation.mBindPoint == VK_PIPELINE_BIND_POINT_GRAPHICS && + observation.mInputCount == 0 && !observation.mHasInputPointer && + observation.mColorCount == LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT && !observation.mHasResolvePointer && + observation.mHasDepthReference && observation.mDepthReference.attachment == 4 && + observation.mDepthReference.layout == LegacyNormSpecRenderPass::depthAttachmentLayout() && + observation.mPreserveCount == 0 && !observation.mHasPreservePointer); +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_material_render_pass_test +{ +}; + +using render_vulkan_material_render_pass_test_group = test_group; +using render_vulkan_material_render_pass_test_object = render_vulkan_material_render_pass_test_group::object; +render_vulkan_material_render_pass_test_group render_vulkan_material_render_pass_tests("render vulkan material render pass"); + +template<> +template<> +void render_vulkan_material_render_pass_test_object::test<1>() +{ + static_assert(LEGACY_NORMSPEC_RENDER_PASS_ATTACHMENT_COUNT == 5); + static_assert(std::variant_size_v == 2); + static_assert(std::is_same_v, MaterialRenderPassCreationError>); + static_assert( + std::is_same_v, std::unique_ptr>); + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_constructible_v); + static_assert(!std::is_move_assignable_v); + static_assert(std::is_nothrow_destructible_v); + static_assert(std::is_nothrow_copy_constructible_v); + static_assert(noexcept(createLegacyNormSpecRenderPass(std::declval(), + std::declval()))); + static_assert(noexcept(std::declval().clearValues())); +} + +template<> +template<> +void render_vulkan_material_render_pass_test_object::test<2>() +{ + ProfileState profile_state; + auto profile_result = resolveProfile(profile_state, legacyNormSpecModernHDRPipelineKey()); + const auto* profile = resolvedProfile(profile_result); + ensure("the fixture profile resolves", profile != nullptr); + + RenderPassState render_state; + MaterialRenderPassDevice device = fakeDevice(profile_state, render_state); + + auto result = createLegacyNormSpecRenderPass({}, *profile); + ensureCreationError("a null physical device is rejected", result, MaterialRenderPassCreationCode::InvalidPhysicalDevice); + + device.mDevice = VK_NULL_HANDLE; + result = createLegacyNormSpecRenderPass(device, *profile); + ensureCreationError("a null logical device is rejected", result, MaterialRenderPassCreationCode::InvalidDevice); + + device = fakeDevice(profile_state, render_state); + device.mDispatch.mCreateRenderPass = nullptr; + result = createLegacyNormSpecRenderPass(device, *profile); + ensureCreationError("missing create dispatch is rejected", result, MaterialRenderPassCreationCode::InvalidDispatch); + + device = fakeDevice(profile_state, render_state); + device.mDispatch.mDestroyRenderPass = nullptr; + result = createLegacyNormSpecRenderPass(device, *profile); + ensureCreationError("missing destroy dispatch is rejected", result, MaterialRenderPassCreationCode::InvalidDispatch); + + device = fakeDevice(profile_state, render_state); + device.mPhysicalDevice = fakeHandle(0x5eedU); + result = createLegacyNormSpecRenderPass(device, *profile); + ensureCreationError("a profile from another physical device is rejected", result, + MaterialRenderPassCreationCode::ProfilePhysicalDeviceMismatch); + ensure_equals("all preflight failures happen before native creation", render_state.mCreateCount, std::size_t{ 0 }); +} + +template<> +template<> +void render_vulkan_material_render_pass_test_object::test<3>() +{ + ProfileState profile_state; + auto profile_result = resolveProfile(profile_state, legacyNormSpecModernHDRPipelineKey()); + const auto* profile = resolvedProfile(profile_result); + ensure("the Modern HDR profile resolves", profile != nullptr); + ensure_equals("the Modern HDR fixture queries physical features once", profile_state.mFeaturesCount, std::size_t{ 1 }); + + RenderPassState render_state; + auto device = fakeDevice(profile_state, render_state); + auto result = createLegacyNormSpecRenderPass(device, *profile); + auto* owner = createdOwner(result); + ensure("one Modern HDR render pass is published", owner != nullptr); + ensure_equals("native creation happens exactly once", render_state.mCreateCount, std::size_t{ 1 }); + ensureExactCreateInfo(render_state.mCreates[0], *profile); + + ensure("the owner retains both device provenances and its accepted handle", + owner->createdOn(device.mDevice) && !owner->createdOn(fakeHandle(0x5eedU)) && + owner->selectedFrom(device.mPhysicalDevice) && !owner->selectedFrom(fakeHandle(0x5eedU)) && + owner->renderPass() == render_state.mOutputs[0]); + ensure("the owner exposes both no-transition attachment layouts", + owner->colorAttachmentLayout() == VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL && + owner->depthAttachmentLayout() == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL); + ensure("the owner retains a distinct immutable copy of the complete profile", + &owner->attachmentProfile() != profile && owner->attachmentProfile().targetProfile() == LegacyNormSpecTargetProfile::ModernHDR && + owner->attachmentProfile().deviceRequirements().independentBlendRequired() && + owner->attachmentProfile().colors()[3].mWriteMask == + (VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT) && + owner->attachmentProfile().colors()[3].mAlphaSemantic == MaterialAttachmentAlphaSemantic::ImplicitOneAfterClear); + + const auto clears = owner->clearValues(); + for (std::size_t slot = 0; slot < 3; ++slot) + { + ensure("the first three color clears remain transparent black", + clears[slot].color.float32[0] == 0.f && clears[slot].color.float32[1] == 0.f && clears[slot].color.float32[2] == 0.f && + clears[slot].color.float32[3] == 0.f); + } + ensure("the widened RGB target retains its alpha-one clear", + clears[3].color.float32[0] == 0.f && clears[3].color.float32[1] == 0.f && clears[3].color.float32[2] == 0.f && + clears[3].color.float32[3] == 1.f); + ensure("depth retains its one/zero clear", clears[4].depthStencil.depth == 1.f && clears[4].depthStencil.stencil == 0); + + profile_result = MaterialAttachmentResolutionError{}; + ensure("destroying the source value does not change the retained profile", + owner->attachmentProfile().targetProfile() == LegacyNormSpecTargetProfile::ModernHDR && + owner->attachmentProfile().deviceRequirements().independentBlendRequired() && + owner->attachmentProfile().colors()[3].mNativeFormat == VK_FORMAT_R16G16B16A16_SFLOAT); + + std::get>(result).reset(); + ensure_equals("the accepted pass is destroyed once", render_state.mDestroyCount, std::size_t{ 1 }); + ensure("destroy uses the creating device, accepted handle, and null allocator", + render_state.mDestroys[0].mDevice == device.mDevice && render_state.mDestroys[0].mRenderPass == render_state.mOutputs[0] && + !render_state.mDestroys[0].mAllocator); +} + +template<> +template<> +void render_vulkan_material_render_pass_test_object::test<4>() +{ + ProfileState profile_state; + auto profile_result = resolveProfile(profile_state, legacyNormSpecCompatibilityPipelineKey()); + const auto* profile = resolvedProfile(profile_result); + ensure("the Compatibility profile resolves", profile != nullptr); + ensure_equals("the Compatibility fixture queries physical features once", profile_state.mFeaturesCount, std::size_t{ 1 }); + + RenderPassState render_state; + auto result = createLegacyNormSpecRenderPass(fakeDevice(profile_state, render_state), *profile); + auto* owner = createdOwner(result); + ensure("one Compatibility render pass is published", owner != nullptr); + ensureExactCreateInfo(render_state.mCreates[0], *profile); + + const std::array expected{ VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_A2B10G10R10_UNORM_PACK32, + VK_FORMAT_R8G8B8A8_UNORM }; + for (std::size_t slot = 0; slot < expected.size(); ++slot) + { + ensure("the Compatibility format order reaches native creation exactly", + render_state.mCreates[0].mAttachments[slot].format == expected[slot]); + } + ensure("the retained Compatibility alpha contract stays coupled", + owner->attachmentProfile().targetProfile() == LegacyNormSpecTargetProfile::Compatibility && + owner->attachmentProfile().deviceRequirements().independentBlendRequired() && + owner->attachmentProfile().colors()[3].mNativeFormat == VK_FORMAT_R8G8B8A8_UNORM && + owner->attachmentProfile().colors()[3].mWriteMask == + (VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT) && + owner->attachmentProfile().colors()[3].mAlphaSemantic == MaterialAttachmentAlphaSemantic::ImplicitOneAfterClear && + owner->clearValues()[3].color.float32[3] == 1.f); +} + +template<> +template<> +void render_vulkan_material_render_pass_test_object::test<5>() +{ + ProfileState profile_state; + auto profile_result = resolveProfile(profile_state, legacyNormSpecModernHDRPipelineKey()); + const auto* profile = resolvedProfile(profile_result); + ensure("the failure fixture profile resolves", profile != nullptr); + + RenderPassState render_state; + render_state.mResults[0] = VK_ERROR_OUT_OF_HOST_MEMORY; + render_state.mOutputs[0] = fakeHandle(0xdeadU); + auto result = createLegacyNormSpecRenderPass(fakeDevice(profile_state, render_state), *profile); + ensureCreationError("a failed create preserves its native result and ignores poisoned output", result, + MaterialRenderPassCreationCode::CreateFailure, VK_ERROR_OUT_OF_HOST_MEMORY); + ensure_equals("failed output is never destroyed", render_state.mDestroyCount, std::size_t{ 0 }); + + render_state.mResults[1] = VK_SUCCESS; + render_state.mOutputs[1] = VK_NULL_HANDLE; + result = createLegacyNormSpecRenderPass(fakeDevice(profile_state, render_state), *profile); + ensureCreationError("success with a null render pass is rejected", result, MaterialRenderPassCreationCode::NullRenderPass); + ensure_equals("null success is never destroyed", render_state.mDestroyCount, std::size_t{ 0 }); + ensure_equals("both native attempts were observed", render_state.mCreateCount, std::size_t{ 2 }); +} + +template<> +template<> +void render_vulkan_material_render_pass_test_object::test<6>() +{ + ProfileState profile_state; + auto profile_result = resolveProfile(profile_state, legacyNormSpecModernHDRPipelineKey()); + const auto* profile = resolvedProfile(profile_result); + ensure("the repeated-handle fixture profile resolves", profile != nullptr); + + RenderPassState render_state; + render_state.mOutputs[0] = fakeHandle(0x4242U); + render_state.mOutputs[1] = render_state.mOutputs[0]; + auto device = fakeDevice(profile_state, render_state); + auto first = createLegacyNormSpecRenderPass(device, *profile); + auto second = createLegacyNormSpecRenderPass(device, *profile); + auto* first_owner = createdOwner(first); + auto* second_owner = createdOwner(second); + ensure("independent successful creates may expose equal opaque values", + first_owner && second_owner && first_owner->renderPass() == second_owner->renderPass()); + + std::get>(first).reset(); + std::get>(second).reset(); + ensure_equals("equal opaque values retain two destruction obligations", render_state.mDestroyCount, std::size_t{ 2 }); + ensure("both destroys retain the same accepted value and null allocator", + render_state.mDestroys[0].mRenderPass == render_state.mOutputs[0] && + render_state.mDestroys[1].mRenderPass == render_state.mOutputs[1] && !render_state.mDestroys[0].mAllocator && + !render_state.mDestroys[1].mAllocator); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendervulkantextureuploadregistry_test.cpp b/indra/llrender/tests/llrendervulkantextureuploadregistry_test.cpp new file mode 100644 index 00000000000..34b885bedc8 --- /dev/null +++ b/indra/llrender/tests/llrendervulkantextureuploadregistry_test.cpp @@ -0,0 +1,519 @@ +/** + * @file llrendervulkantextureuploadregistry_test.cpp + * @brief Context-free tests for the Vulkan streamed-upload registry and mapping. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendervulkantextureupload.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ + +constexpr LLRenderVulkanTextureUpload::NativeOwnershipToken OWNER_TOKEN = + 0x51a6e15f0c0ffee1ULL; + +template +Handle fakeHandle(std::uintptr_t value) +{ + if constexpr (std::is_pointer_v) + { + return reinterpret_cast(value); + } + else + { + return static_cast(value); + } +} + +LLRenderVulkanTextureUpload::BufferBinding completeBufferBinding( + std::uintptr_t base, void* mapped, VkDeviceSize size, + VkBufferUsageFlags usage) +{ + LLRenderVulkanTextureUpload::BufferBinding binding; + binding.mBuffer = fakeHandle(base); + binding.mMemory = fakeHandle(base + 1); + binding.mOwnershipToken = OWNER_TOKEN; + binding.mMapped = mapped; + binding.mSize = size; + binding.mAllocationSize = size; + binding.mMemoryOffset = 0; + binding.mCreateFlags = 0; + binding.mUsage = usage; + binding.mSharingMode = VK_SHARING_MODE_EXCLUSIVE; + binding.mMemoryProperties = + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + return binding; +} + +LLRenderVulkanTextureUpload::ImageBinding completeImageBinding( + std::uintptr_t base, LLRenderContract::Extent2D resident, + LLRenderContract::Extent2D logical, std::uint32_t discard, + std::uint32_t mip_levels, VkImageUsageFlags usage) +{ + LLRenderVulkanTextureUpload::ImageBinding image; + image.mImage = fakeHandle(base); + image.mView = fakeHandle(base + 1); + image.mMemory = fakeHandle(base + 2); + image.mOwnershipToken = OWNER_TOKEN; + image.mAllocationSize = 4096; + image.mMemoryOffset = 0; + image.mCreateFlags = 0; + image.mImageType = VK_IMAGE_TYPE_2D; + image.mFormat = VK_FORMAT_R8G8B8A8_UNORM; + image.mResidentExtent = resident; + image.mLogicalExtent = logical; + image.mResidentDiscard = discard; + image.mMipLevels = mip_levels; + image.mArrayLayers = 1; + image.mSamples = VK_SAMPLE_COUNT_1_BIT; + image.mTiling = VK_IMAGE_TILING_OPTIMAL; + image.mUsage = usage; + image.mSharingMode = VK_SHARING_MODE_EXCLUSIVE; + image.mAspect = VK_IMAGE_ASPECT_COLOR_BIT; + image.mLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + image.mViewType = VK_IMAGE_VIEW_TYPE_2D; + image.mViewFormat = VK_FORMAT_R8G8B8A8_UNORM; + image.mViewComponents = { + VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY, + VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY + }; + image.mViewRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, mip_levels, 0, 1 }; + return image; +} + +LLRenderVulkanTextureUpload::PipelineBinding completePipelineBinding( + VkImageView color_view) +{ + using namespace LLRenderVulkanTextureUpload; + + PipelineBinding pipeline; + pipeline.mProgram = { "contract.sample-texture", 0 }; + pipeline.mPipeline = fakeHandle(0x500); + pipeline.mLayout = fakeHandle(0x501); + pipeline.mRenderPass = fakeHandle(0x502); + pipeline.mFramebuffer = fakeHandle(0x503); + pipeline.mDescriptorSet = fakeHandle(0x504); + pipeline.mOwnershipToken = OWNER_TOKEN; + pipeline.mExtent = { + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_WIDTH, + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_HEIGHT + }; + pipeline.mColorView = color_view; + pipeline.mColorFormat = VK_FORMAT_R8G8B8A8_UNORM; + pipeline.mVertexShaderIdentity[0] = 0x5a; + pipeline.mFragmentShaderIdentity[0] = 0xa5; + return pipeline; +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_texture_upload_registry_test +{ +}; + +using render_vulkan_texture_upload_registry_group = + test_group; +using render_vulkan_texture_upload_registry_object = + render_vulkan_texture_upload_registry_group::object; +render_vulkan_texture_upload_registry_group + render_vulkan_texture_upload_registry_tests("render Vulkan texture upload registry"); + +template<> +template<> +void render_vulkan_texture_upload_registry_object::test<1>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanTextureUpload; + + std::array screen_bytes{}; + std::array staging_bytes{}; + std::array readback_bytes{}; + constexpr VkImageUsageFlags streamed_usage = + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT; + + BufferBinding screen = completeBufferBinding( + 0x100, screen_bytes.data(), screen_bytes.size(), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + screen.mHasFixtureBytes = true; + screen.mFixtureBytes = screen_bytes; + ImageBinding old_image = completeImageBinding( + 0x200, { 8, 4 }, { 32, 16 }, 2, 3, streamed_usage); + old_image.mHasPreExecutionMipSnapshot = true; + old_image.mPreExecutionMipRGBA8 = makeTextureUploadFixture().mOldMipRGBA8; + const ImageBinding replacement = completeImageBinding( + 0x210, { 8, 4 }, { 32, 16 }, 2, 3, streamed_usage); + const ImageBinding output = completeImageBinding( + 0x220, { 4, 2 }, { 4, 2 }, 0, 1, + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT); + SamplerBinding sampler; + sampler.mSampler = fakeHandle(0x300); + sampler.mOwnershipToken = OWNER_TOKEN; + const PipelineBinding pipeline = completePipelineBinding(output.mView); + TransferResources transfer{ + completeBufferBinding(0x400, staging_bytes.data(), staging_bytes.size(), + VK_BUFFER_USAGE_TRANSFER_SRC_BIT), + completeBufferBinding(0x410, readback_bytes.data(), readback_bytes.size(), + VK_BUFFER_USAGE_TRANSFER_DST_BIT) + }; + LifecycleLedger lifecycle{ { 11, 4 }, TEXTURE_UPLOAD_PRIOR_REVISION }; + + Registry registry; + ensure("screen registration succeeds", + registry.addScreenTriangle({ 1, 7 }, screen)); + ensure("consecutive image generations register", + registry.addImageGenerations({ 11, 4 }, old_image, { 11, 5 }, replacement)); + ensure("output registration succeeds", registry.addOutput({ 12, 3 }, output)); + ensure("sampler registration succeeds", registry.addSampler({ 2, 8 }, sampler)); + ensure("pipeline registration succeeds", registry.addPipeline({ 3, 9 }, pipeline)); + ensure("transfer resources register", registry.addTransferResources(transfer)); + ensure("lifecycle registration succeeds", registry.addLifecycle(&lifecycle)); + + ensure("exact screen generation resolves", registry.resolve(BufferHandle{ 1, 7 }) != nullptr); + ensure("exact old generation resolves", registry.resolveRegisteredImage({ 11, 4 }) != nullptr); + ensure("exact replacement generation resolves", registry.resolveRegisteredImage({ 11, 5 }) != nullptr); + ensure("exact output generation resolves", registry.resolveOutput({ 12, 3 }) != nullptr); + ensure("exact sampler generation resolves", registry.resolve(SamplerHandle{ 2, 8 }) != nullptr); + ensure("exact pipeline and program resolve", + registry.resolve(PipelineHandle{ 3, 9 }, { "contract.sample-texture", 0 }) != nullptr); + ensure("transfer registration is retained", registry.transferResources() != nullptr); + ensure("registry borrows lifecycle", registry.lifecycle() == &lifecycle); + ensure("published old generation resolves", registry.isResolvable({ 11, 4 })); + ensure("unpublished replacement does not resolve", !registry.isResolvable({ 11, 5 })); +} + +template<> +template<> +void render_vulkan_texture_upload_registry_object::test<2>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanTextureUpload; + + std::array bytes{}; + BufferBinding screen = completeBufferBinding( + 0x100, bytes.data(), bytes.size(), VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + screen.mHasFixtureBytes = true; + ImageBinding first = completeImageBinding( + 0x200, { 8, 4 }, { 32, 16 }, 2, 3, + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT); + ImageBinding second = completeImageBinding( + 0x210, { 8, 4 }, { 32, 16 }, 2, 3, + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT); + first.mHasPreExecutionMipSnapshot = true; + + BufferBinding incomplete_screen = screen; + incomplete_screen.mMemory = VK_NULL_HANDLE; + ensure("incomplete screen is rejected", + !Registry{}.addScreenTriangle({ 1, 1 }, incomplete_screen)); + BufferBinding ownerless_screen = screen; + ownerless_screen.mOwnershipToken = 0; + ensure("screen without native ownership is rejected", + !Registry{}.addScreenTriangle({ 1, 1 }, ownerless_screen)); + ensure("different image indices are rejected", + !Registry{}.addImageGenerations({ 11, 1 }, first, { 12, 2 }, second)); + ensure("skipped image generation is rejected", + !Registry{}.addImageGenerations({ 11, 1 }, first, { 11, 3 }, second)); + ensure("generation overflow is rejected", + !Registry{}.addImageGenerations( + { 11, std::numeric_limits::max() }, first, + { 11, 1 }, second)); + second.mMemory = first.mMemory; + ensure("aliased image memory is rejected", + !Registry{}.addImageGenerations({ 11, 1 }, first, { 11, 2 }, second)); + + ImageBinding ownerless_old = first; + ownerless_old.mOwnershipToken = 0; + ensure("image without native ownership is rejected", + !Registry{}.addImageGenerations({ 11, 1 }, ownerless_old, + { 11, 2 }, completeImageBinding( + 0x220, { 8, 4 }, { 32, 16 }, 2, 3, + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT))); + + PipelineBinding pipeline = completePipelineBinding(fakeHandle(0x221)); + pipeline.mVertexShaderIdentity = {}; + ensure("pipeline without immutable shader identity is rejected", + !Registry{}.addPipeline({ 1, 1 }, pipeline)); + pipeline = completePipelineBinding(fakeHandle(0x221)); + pipeline.mOwnershipToken = 0; + ensure("pipeline without native ownership is rejected", + !Registry{}.addPipeline({ 1, 1 }, pipeline)); + + SamplerBinding ownerless_sampler; + ownerless_sampler.mSampler = fakeHandle(0x300); + ensure("sampler without native ownership is rejected", + !Registry{}.addSampler({ 1, 1 }, ownerless_sampler)); + + LifecycleLedger pending{ { 11, 1 }, TEXTURE_UPLOAD_PRIOR_REVISION, true }; + ensure("pending lifecycle is rejected", !Registry{}.addLifecycle(&pending)); + LifecycleLedger completed{ { 11, 1 }, TEXTURE_UPLOAD_PRIOR_REVISION }; + completed.mCompletionCount = 1; + ensure("prepublished completion is rejected", !Registry{}.addLifecycle(&completed)); +} + +template<> +template<> +void render_vulkan_texture_upload_registry_object::test<3>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanTextureUpload; + + ensure_equals("screen triangle stays exactly 48 bytes", + SCREEN_TRIANGLE_BYTE_SIZE, static_cast(48)); + ensure_equals("staging stays exactly 144 bytes", + STAGING_BYTE_SIZE, static_cast(144)); + ensure_equals("readback stays exactly 200 bytes", + READBACK_BYTE_SIZE, static_cast(200)); + ensure_equals("output follows the three tight mips", + OUTPUT_READBACK_BYTE_OFFSET, static_cast(168)); + const std::array expected_rows{ 108, 72, 36, 0 }; + ensure("row regions reverse only source row order", + UPLOAD_ROW_SOURCE_OFFSETS == expected_rows); + ensure_equals("mip zero readback begins at zero", + TEXTURE_UPLOAD_MIP_BYTE_OFFSETS[0], static_cast(0)); + ensure_equals("mip one readback begins at 128", + TEXTURE_UPLOAD_MIP_BYTE_OFFSETS[1], static_cast(128)); + ensure_equals("mip two readback begins at 160", + TEXTURE_UPLOAD_MIP_BYTE_OFFSETS[2], static_cast(160)); +} + +template<> +template<> +void render_vulkan_texture_upload_registry_object::test<4>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanTextureUpload; + + const TextureUploadCase upload_case = makeTextureUploadCase(); + Registry registry; + LifecycleLedger lifecycle{ + upload_case.mInputs.mHandles.mOldImage, TEXTURE_UPLOAD_PRIOR_REVISION + }; + ensure("lifecycle setup succeeds", registry.addLifecycle(&lifecycle)); + const LifecycleLedger lifecycle_before = lifecycle; + + ExecutionResult result = makeTextureUploadArtifact(); + result.mSampledRGBA8 = { 0x5a }; + const ExecutionResult result_before = result; + std::uint64_t recording_attempts = 0; + std::uint64_t submissions = 0; + ExecutionContext context; + context.mDevice = fakeHandle(0x600); + context.mCommandPool = fakeHandle(0x603); + context.mCommandBuffer = fakeHandle(0x601); + context.mQueue = fakeHandle(0x602); + context.mOwnershipToken = OWNER_TOKEN; + context.mQueueFamilyIndex = 2; + context.mQueueFamilyFlags = VK_QUEUE_GRAPHICS_BIT | VK_QUEUE_TRANSFER_BIT; + context.mQueueCount = 1; + context.mQueueIndex = 0; + context.mCommandPoolQueueFamilyIndex = 2; + context.mCommandPoolFlags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + context.mCommandBufferLevel = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + context.mRecordingAttemptCount = &recording_attempts; + context.mSubmissionCount = &submissions; + context.mRequiredVertexShaderIdentity[0] = 1; + context.mRequiredFragmentShaderIdentity[0] = 2; + + std::string error; + ensure("unresolved registry is rejected before Vulkan", + !execute(upload_case.mFrame, registry, context, result, &error)); + ensure("preflight rejection explains failure", !error.empty()); + ensure_equals("preflight performs no recording attempt", recording_attempts, + std::uint64_t{ 0 }); + ensure_equals("preflight performs no submission", submissions, + std::uint64_t{ 0 }); + ensure("preflight leaves lifecycle unchanged", lifecycle == lifecycle_before); + ensure("preflight leaves caller result unchanged", result == result_before); +} + +template<> +template<> +void render_vulkan_texture_upload_registry_object::test<5>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanTextureUpload; + + const TextureUploadCase upload_case = makeTextureUploadCase(); + std::uint64_t recording_attempts = 0; + std::uint64_t submissions = 0; + ExecutionContext canonical; + canonical.mDevice = fakeHandle(0x700); + canonical.mCommandPool = fakeHandle(0x701); + canonical.mCommandBuffer = fakeHandle(0x702); + canonical.mQueue = fakeHandle(0x703); + canonical.mOwnershipToken = OWNER_TOKEN; + canonical.mQueueFamilyIndex = 3; + canonical.mQueueFamilyFlags = VK_QUEUE_GRAPHICS_BIT | VK_QUEUE_TRANSFER_BIT; + canonical.mQueueCount = 1; + canonical.mQueueIndex = 0; + canonical.mCommandPoolQueueFamilyIndex = 3; + canonical.mCommandPoolFlags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + canonical.mCommandBufferLevel = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + canonical.mRecordingAttemptCount = &recording_attempts; + canonical.mSubmissionCount = &submissions; + canonical.mRequiredVertexShaderIdentity[0] = 1; + canonical.mRequiredFragmentShaderIdentity[0] = 2; + + std::array poisoned{}; + poisoned.fill(canonical); + poisoned[0].mOwnershipToken = 0; + poisoned[1].mCommandPool = VK_NULL_HANDLE; + poisoned[2].mQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + poisoned[3].mQueueFamilyFlags = VK_QUEUE_TRANSFER_BIT; + poisoned[4].mQueueFamilyFlags = VK_QUEUE_GRAPHICS_BIT; + poisoned[5].mQueueCount = 0; + poisoned[6].mQueueCount = 2; + poisoned[7].mQueueIndex = 1; + poisoned[8].mCommandPoolQueueFamilyIndex = 4; + poisoned[9].mCommandPoolFlags = 0; + poisoned[10].mCommandBufferLevel = VK_COMMAND_BUFFER_LEVEL_SECONDARY; + + for (const ExecutionContext& context : poisoned) + { + Registry registry; + ExecutionResult result = makeTextureUploadArtifact(); + result.mSampledRGBA8 = { 0x7b }; + const ExecutionResult result_before = result; + std::string error; + ensure("poisoned command context is rejected", + !execute(upload_case.mFrame, registry, context, result, &error)); + ensure_equals("context poison is identified before registry resolution", + error, std::string("execution context is incomplete")); + ensure("context rejection leaves caller result unchanged", + result == result_before); + } + ensure_equals("context poison performs no recording attempt", recording_attempts, + std::uint64_t{ 0 }); + ensure_equals("context poison performs no submission", submissions, + std::uint64_t{ 0 }); +} + +template<> +template<> +void render_vulkan_texture_upload_registry_object::test<6>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanTextureUpload; + + const TextureUploadCase upload_case = makeTextureUploadCase(); + const TextureUploadFixture fixture = makeTextureUploadFixture(); + const StreamingUploadHandles& handles = upload_case.mInputs.mHandles; + constexpr VkImageUsageFlags streamed_usage = + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT; + std::array screen_bytes{}; + std::memcpy(screen_bytes.data(), fixture.mScreenTriangle.data(), + screen_bytes.size()); + std::array staging_bytes{}; + staging_bytes.fill(0xa7); + const auto staging_before = staging_bytes; + std::array readback_bytes{}; + + BufferBinding screen = completeBufferBinding( + 0x800, screen_bytes.data(), screen_bytes.size(), + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + screen.mHasFixtureBytes = true; + screen.mFixtureBytes = screen_bytes; + ImageBinding old_image = completeImageBinding( + 0x810, { 8, 4 }, { 32, 16 }, 2, 3, streamed_usage); + old_image.mHasPreExecutionMipSnapshot = true; + old_image.mPreExecutionMipRGBA8 = fixture.mOldMipRGBA8; + const ImageBinding replacement = completeImageBinding( + 0x820, { 8, 4 }, { 32, 16 }, 2, 3, streamed_usage); + const ImageBinding output = completeImageBinding( + 0x830, { 4, 2 }, { 4, 2 }, 0, 1, + streamed_usage | VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT); + SamplerBinding sampler; + sampler.mSampler = fakeHandle(0x840); + sampler.mOwnershipToken = OWNER_TOKEN + 1; + const PipelineBinding pipeline = completePipelineBinding(output.mView); + TransferResources transfer{ + completeBufferBinding(0x850, staging_bytes.data(), staging_bytes.size(), + VK_BUFFER_USAGE_TRANSFER_SRC_BIT), + completeBufferBinding(0x860, readback_bytes.data(), readback_bytes.size(), + VK_BUFFER_USAGE_TRANSFER_DST_BIT) + }; + LifecycleLedger lifecycle{ handles.mOldImage, TEXTURE_UPLOAD_PRIOR_REVISION }; + const LifecycleLedger lifecycle_before = lifecycle; + Registry registry; + ensure("ownership fixture screen registers", + registry.addScreenTriangle(handles.mScreenTriangle, screen)); + ensure("ownership fixture images register", + registry.addImageGenerations(handles.mOldImage, old_image, + handles.mReplacementImage, replacement)); + ensure("ownership fixture output registers", + registry.addOutput(handles.mOutput, output)); + ensure("nonzero mismatched sampler owner registers", + registry.addSampler(handles.mSampler, sampler)); + ensure("ownership fixture pipeline registers", + registry.addPipeline(handles.mPipeline, pipeline)); + ensure("ownership fixture transfer registers", + registry.addTransferResources(transfer)); + ensure("ownership fixture lifecycle registers", registry.addLifecycle(&lifecycle)); + + std::uint64_t recording_attempts = 0; + std::uint64_t submissions = 0; + ExecutionContext context; + context.mDevice = fakeHandle(0x870); + context.mCommandPool = fakeHandle(0x871); + context.mCommandBuffer = fakeHandle(0x872); + context.mQueue = fakeHandle(0x873); + context.mOwnershipToken = OWNER_TOKEN; + context.mQueueFamilyIndex = 1; + context.mQueueFamilyFlags = VK_QUEUE_GRAPHICS_BIT | VK_QUEUE_TRANSFER_BIT; + context.mQueueCount = 1; + context.mCommandPoolQueueFamilyIndex = 1; + context.mCommandPoolFlags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + context.mCommandBufferLevel = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + context.mRecordingAttemptCount = &recording_attempts; + context.mSubmissionCount = &submissions; + context.mRequiredVertexShaderIdentity[0] = 1; + context.mRequiredFragmentShaderIdentity[0] = 2; + ExecutionResult result = makeTextureUploadArtifact(); + result.mSampledRGBA8 = { 0x42 }; + const ExecutionResult result_before = result; + std::string error; + ensure("mismatched native ownership is rejected", + !execute(upload_case.mFrame, registry, context, result, &error)); + ensure_equals("native ownership mismatch is identified", error, + std::string("native resources do not share the execution context owner")); + ensure("ownership rejection leaves staging untouched", + staging_bytes == staging_before); + ensure("ownership rejection leaves lifecycle untouched", + lifecycle == lifecycle_before); + ensure("ownership rejection leaves caller result untouched", + result == result_before); + ensure_equals("ownership rejection performs no recording", recording_attempts, + std::uint64_t{ 0 }); + ensure_equals("ownership rejection performs no submission", submissions, + std::uint64_t{ 0 }); +} + +} // namespace tut diff --git a/indra/llrender/tests/llrendervulkantonemapregistry_test.cpp b/indra/llrender/tests/llrendervulkantonemapregistry_test.cpp new file mode 100644 index 00000000000..27642770b1a --- /dev/null +++ b/indra/llrender/tests/llrendervulkantonemapregistry_test.cpp @@ -0,0 +1,135 @@ +/** + * @file llrendervulkantonemapregistry_test.cpp + * @brief Focused tests for the Vulkan tonemap registry. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llrendervulkantonemap.h" +#include "lltut.h" + +#include +#include + +namespace +{ + +template +Handle fakeHandle(std::uintptr_t value) +{ + if constexpr (std::is_pointer_v) + { + return reinterpret_cast(value); + } + else + { + return static_cast(value); + } +} + +} // namespace + +namespace tut +{ + +struct render_vulkan_tonemap_registry_test +{ +}; + +using render_vulkan_tonemap_registry_group = test_group; +using render_vulkan_tonemap_registry_object = render_vulkan_tonemap_registry_group::object; +render_vulkan_tonemap_registry_group render_vulkan_tonemap_registry_tests("render Vulkan tonemap registry"); + +template<> +template<> +void render_vulkan_tonemap_registry_object::test<1>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanTonemap; + + Registry registry; + const BufferHandle buffer_handle{ 1, 4 }; + const ImageHandle image_handle{ 1, 5 }; + const SamplerHandle sampler_handle{ 1, 6 }; + const PipelineHandle pipeline_handle{ 1, 7 }; + + BufferBinding buffer{ fakeHandle(0x10), 48, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT }; + ImageBinding image{ fakeHandle(0x20), fakeHandle(0x21), + VK_FORMAT_R8G8B8A8_UNORM, { 8, 8 }, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT }; + SamplerBinding sampler{ fakeHandle(0x30), Filter::Nearest, Filter::Nearest, + AddressMode::Mirror, AddressMode::Mirror }; + PipelineBinding pipeline; + pipeline.mProgram = { "deferred.tonemap", 3 }; + pipeline.mDestinationFormat = PixelFormat::RGBA8Unorm; + pipeline.mExtent = { 8, 8 }; + pipeline.mPipeline = fakeHandle(0x40); + pipeline.mLayout = fakeHandle(0x41); + pipeline.mRenderPass = fakeHandle(0x42); + pipeline.mFramebuffer = fakeHandle(0x43); + pipeline.mDescriptorSet = fakeHandle(0x44); + pipeline.mSceneView = fakeHandle(0x50); + pipeline.mExposureView = fakeHandle(0x51); + pipeline.mDestinationView = image.mView; + pipeline.mPointSampler = sampler.mSampler; + pipeline.mLinearSampler = fakeHandle(0x31); + pipeline.mDestinationFinalLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + pipeline.mDescriptorBindings = { 0, 1 }; + pipeline.mVertexStride = 16; + pipeline.mPositionFormat = VK_FORMAT_R32G32B32_SFLOAT; + pipeline.mPushConstantSize = 16; + + ensure("buffer registers", registry.addBuffer(buffer_handle, buffer)); + ensure("image registers", registry.addImage(image_handle, image)); + ensure("sampler registers", registry.addSampler(sampler_handle, sampler)); + ensure("pipeline registers", registry.addPipeline(pipeline_handle, pipeline)); + ensure("exact buffer resolves", registry.resolve(buffer_handle) != nullptr); + ensure("exact image resolves", registry.resolve(image_handle) != nullptr); + ensure("exact sampler resolves", registry.resolve(sampler_handle) != nullptr); + ensure("exact pipeline resolves", registry.resolve(pipeline_handle, { "deferred.tonemap", 3 }) != nullptr); +} + +template<> +template<> +void render_vulkan_tonemap_registry_object::test<2>() +{ + using namespace LLRenderContract; + using namespace LLRenderVulkanTonemap; + + Registry registry; + const ImageBinding image{ fakeHandle(0x20), fakeHandle(0x21), + VK_FORMAT_R8G8B8A8_UNORM, { 8, 8 }, VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT }; + PipelineBinding pipeline; + pipeline.mProgram = { "deferred.tonemap", 3 }; + pipeline.mPipeline = fakeHandle(0x40); + pipeline.mLayout = fakeHandle(0x41); + pipeline.mRenderPass = fakeHandle(0x42); + pipeline.mFramebuffer = fakeHandle(0x43); + pipeline.mDescriptorSet = fakeHandle(0x44); + + ensure("image registers", registry.addImage({ 2, 7 }, image)); + ensure("pipeline registers", registry.addPipeline({ 4, 9 }, pipeline)); + ensure("stale image generation is rejected", registry.resolve(ImageHandle{ 2, 6 }) == nullptr); + ensure("unknown image index is rejected", registry.resolve(ImageHandle{ 8, 7 }) == nullptr); + ensure("program variant mismatch is rejected", + registry.resolve(PipelineHandle{ 4, 9 }, { "deferred.tonemap", 2 }) == nullptr); + ensure("program name mismatch is rejected", + registry.resolve(PipelineHandle{ 4, 9 }, { "other.tonemap", 3 }) == nullptr); + ensure("stale pipeline generation is rejected", + registry.resolve(PipelineHandle{ 4, 8 }, { "deferred.tonemap", 3 }) == nullptr); + ensure("duplicate live index is rejected", !registry.addImage({ 2, 8 }, image)); + ImageBinding incomplete = image; + incomplete.mView = VK_NULL_HANDLE; + ensure("incomplete image is rejected", !registry.addImage({ 9, 1 }, incomplete)); +} + +} // namespace tut diff --git a/indra/llrender/tests/llshadermanifest_test.cpp b/indra/llrender/tests/llshadermanifest_test.cpp new file mode 100644 index 00000000000..13c7d253fab --- /dev/null +++ b/indra/llrender/tests/llshadermanifest_test.cpp @@ -0,0 +1,434 @@ +/** + * @file llshadermanifest_test.cpp + * @brief Tests for the canonical legacy material shader manifests. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llshadermanifest.h" +#include "lltut.h" + +#include +#include + +namespace +{ +using namespace LLRenderContract; + +template +const typename Range::value_type* findValue(const Range& values, Predicate predicate) +{ + const auto found = std::find_if(values.begin(), values.end(), predicate); + return found == values.end() ? nullptr : &*found; +} + +} // namespace + +namespace tut +{ + +struct shader_manifest_test +{ +}; + +using shader_manifest_test_group = test_group; +using shader_manifest_test_object = shader_manifest_test_group::object; +shader_manifest_test_group shader_manifest_tests("shader manifest"); + +template<> +template<> +void shader_manifest_test_object::test<1>() +{ + const ShaderManifest manifest = legacyNormSpecDiagnosticShaderManifest(ShaderBackend::OpenGL); + ensure("OpenGL manifest is structurally valid", validShaderManifest(manifest)); + ensure("OpenGL manifest is the exact diagnostic recipe", validLegacyNormSpecDiagnosticShaderManifest(manifest)); + ensure("diagnostic semantic program is explicit", + manifest.mProgram.mName == LEGACY_NORMSPEC_PIPELINE_NAME && manifest.mProgram.mVariant == LEGACY_NORMSPEC_DIAGNOSTIC_VARIANT); + ensure("OpenGL primary and feature assembly order is complete", + manifest.mSourceUnits.size() == 20 && manifest.mSourceUnits[0].mPath == "deferred/materialV.glsl" && + manifest.mSourceUnits[0].mShaderClass == 1 && manifest.mSourceUnits[1].mPath == "deferred/materialF.glsl" && + manifest.mSourceUnits[1].mShaderClass == 3 && manifest.mSourceUnits[12].mPath == "deferred/deferredUtil.glsl" && + manifest.mSourceUnits[19].mPath == "objects/nonindexedTextureV.glsl"); + const auto* screen_space = findValue(manifest.mSourceUnits, [](const ShaderSourceUnit& source) + { return source.mPath == "deferred/screenSpaceReflUtil.glsl"; }); + const auto* reflection_probe = + findValue(manifest.mSourceUnits, [](const ShaderSourceUnit& source) { return source.mPath == "deferred/reflectionProbeF.glsl"; }); + ensure("OpenGL resolved feature classes are exact", + std::all_of(manifest.mSourceUnits.begin(), manifest.mSourceUnits.end(), + [](const ShaderSourceUnit& source) { return source.mShaderClass.has_value(); }) && + screen_space && screen_space->mShaderClass == 1 && reflection_probe && reflection_probe->mShaderClass == 3); + ensure( + "diagnostic assembly omits emissive and shadow permutations", + manifest.mDefines.size() == 3 && + !findValue(manifest.mDefines, [](const ShaderDefine& define) { return define.mName == "HAS_EMISSIVE"; }) && + !findValue(manifest.mDefines, [](const ShaderDefine& define) { return define.mName == "HAS_SUN_SHADOW"; }) && + !findValue(manifest.mSourceUnits, [](const ShaderSourceUnit& source) { return source.mPath == "deferred/shadowUtil.glsl"; })); + + const auto* color = + findValue(manifest.mVertexInputs, [](const ShaderVertexInput& input) { return input.mSemantic == VertexSemantic::Color; }); + const auto* tangent = + findValue(manifest.mVertexInputs, [](const ShaderVertexInput& input) { return input.mSemantic == VertexSemantic::Tangent; }); + ensure("OpenGL sparse attribute mapping is recorded", + manifest.mVertexInputs.size() == 7 && color && color->mLocation == 6 && color->mStride == 4 && tangent && + tangent->mLocation == 8 && tangent->mStride == 16); + ensure("OpenGL sampler channels are exact", manifest.mSampledImages.size() == 3 && manifest.mSampledImages[0].mSet == 0 && + manifest.mSampledImages[0].mBinding == 0 && manifest.mSampledImages[2].mBinding == 2); + + const auto* mirror = + findValue(manifest.mLogicalParameters, [](const ShaderLogicalParameter& parameter) { return parameter.mName == "mirror"; }); + ensure("all 68 words have named origins", + manifest.mLogicalParameters.size() == 9 && manifest.mLogicalParameters.front().mWordOffset == 0 && mirror && + mirror->mWordOffset == 67 && mirror->mWordCount == 1 && mirror->mOrigin == ShaderParameterOrigin::FixedDefault && + mirror->mFixedValue == 0.f); + ensure("logical writes are separate from the inert linked declaration", + manifest.mLogicalFragmentOutputs.size() == 3 && manifest.mFragmentOutputDeclarations.size() == 1 && + manifest.mFragmentOutputDeclarations[0].mElementCount == 4 && + manifest.mFragmentOutputDeclarations[0].mLogicalElementCount == 3 && + manifest.mFragmentOutputDeclarations[0].mExtraElementsInert); + ensure("OpenGL linked reflection baggage is explicit", + manifest.mLinkedBlockBaggage.size() == 1 && manifest.mLinkedBlockBaggage[0].mName == "ReflectionProbes" && + manifest.mLinkedBlockBaggage[0].mBinding == 0 && manifest.mLinkedBlockBaggage[0].mByteSize == 49248 && + manifest.mLinkedBlockBaggage[0].mActiveMembers.size() == 12); +} + +template<> +template<> +void shader_manifest_test_object::test<2>() +{ + const ShaderManifest manifest = legacyNormSpecDiagnosticShaderManifest(ShaderBackend::Vulkan); + ensure("Vulkan manifest is structurally valid", validShaderManifest(manifest)); + ensure("Vulkan manifest is the exact diagnostic recipe", validLegacyNormSpecDiagnosticShaderManifest(manifest)); + ensure("Vulkan wrapper and shared source order is exact", + manifest.mSourceUnits.size() == 5 && manifest.mSourceUnits[0].mPath == "indra/llrender/vulkan/shaders/material.vert.glsl" && + manifest.mSourceUnits[1].mPath == "indra/newview/app_settings/shaders/class1/deferred/materialV.glsl" && + manifest.mSourceUnits[2].mPath == "indra/llrender/vulkan/shaders/material.frag.glsl" && + manifest.mSourceUnits[4].mShaderClass == 3); + ensure("Vulkan declaration defines remain diagnostic", + manifest.mDefines.size() == 9 && manifest.mDefines.front().mName == "LL_VULKAN_SHADER" && + manifest.mDefines.back().mName == "GBUFFER_FLAG_HAS_ATMOS" && manifest.mDefines.back().mValue == "0.34"); + ensure("Vulkan vertex locations and bindings are dense", + manifest.mVertexInputs.size() == 7 && manifest.mVertexInputs[0].mLocation == 0 && manifest.mVertexInputs[0].mBinding == 0 && + manifest.mVertexInputs[3].mLocation == 3 && manifest.mVertexInputs[3].mBinding == 3 && + manifest.mVertexInputs[6].mLocation == 6 && manifest.mVertexInputs[6].mBinding == 6); + + const auto* flat_sign = + findValue(manifest.mInterstageVariables, [](const ShaderInterstageVariable& variable) { return variable.mName == "vary_sign"; }); + ensure("the complete Vulkan stage interface preserves flat tangent sign", + manifest.mInterstageVariables.size() == 8 && flat_sign && flat_sign->mLocation == 2 && + flat_sign->mType == ShaderValueType::Float && flat_sign->mInterpolation == ShaderInterpolation::Flat); + ensure("Vulkan sampled images occupy set one", + std::all_of(manifest.mSampledImages.begin(), manifest.mSampledImages.end(), + [](const ShaderSampledImage& image) { return image.mSet == 1 && image.mVisibility.mFragment; })); + ensure("the 272-byte packet is visible to both stages at set zero", + manifest.mParameterBlock && manifest.mParameterBlock->mName == "MaterialParameterPacket" && + manifest.mParameterBlock->mSet == 0 && manifest.mParameterBlock->mBinding == 0 && + manifest.mParameterBlock->mByteSize == 272 && manifest.mParameterBlock->mVisibility.mVertex && + manifest.mParameterBlock->mVisibility.mFragment); + ensure("Vulkan declares only the three proven outputs and no hidden resources", + manifest.mFragmentOutputDeclarations.size() == 1 && manifest.mFragmentOutputDeclarations[0].mElementCount == 3 && + manifest.mFragmentOutputDeclarations[0].mLogicalElementCount == 3 && + !manifest.mFragmentOutputDeclarations[0].mExtraElementsInert && manifest.mLinkedBlockBaggage.empty() && + manifest.mPushConstantRanges.empty()); +} + +template<> +template<> +void shader_manifest_test_object::test<3>() +{ + LegacyNormSpecPipelineKey diagnostic_key = legacyNormSpecDiagnosticPipelineKey(); + const auto resolved = legacyNormSpecShaderManifest(diagnostic_key, ShaderBackend::OpenGL); + ensure("the diagnostic key resolves", resolved.has_value()); + const ShaderManifest owned = *resolved; + + diagnostic_key.mProgram.mName.assign("mutated.after.lookup"); + diagnostic_key.mColorTargets.clear(); + ensure("lookup returns value-owned strings and arrays", + *resolved == owned && resolved->mProgram.mName == LEGACY_NORMSPEC_PIPELINE_NAME); + + ensure("both production profiles are valid descriptions", validLegacyNormSpecPipelineKey(legacyNormSpecModernHDRPipelineKey()) && + validLegacyNormSpecPipelineKey(legacyNormSpecCompatibilityPipelineKey())); + ensure("production compatibility has no OpenGL artifact", + !legacyNormSpecShaderManifest(legacyNormSpecCompatibilityPipelineKey(), ShaderBackend::OpenGL)); + + LegacyNormSpecPipelineKey changed = legacyNormSpecDiagnosticPipelineKey(); + changed.mShaderVariant.mEmissive = LegacyNormSpecEmissive::Enabled; + ensure("emissive dimension mutation cannot alias the diagnostic", !legacyNormSpecShaderManifest(changed, ShaderBackend::OpenGL)); + changed = legacyNormSpecDiagnosticPipelineKey(); + changed.mShaderVariant.mShadowAssembly = ShadowAssembly::Sun; + ensure("shadow assembly mutation cannot alias the diagnostic", !legacyNormSpecShaderManifest(changed, ShaderBackend::Vulkan)); + changed = legacyNormSpecDiagnosticPipelineKey(); + changed.mTargetProfile = LegacyNormSpecTargetProfile::Compatibility; + ensure("target-profile mutation cannot alias the diagnostic", !legacyNormSpecShaderManifest(changed, ShaderBackend::OpenGL)); + changed = legacyNormSpecDiagnosticPipelineKey(); + changed.mProgram.mVariant = LEGACY_NORMSPEC_PRODUCTION_VARIANT; + ensure("encoded program mutation cannot alias the diagnostic", !legacyNormSpecShaderManifest(changed, ShaderBackend::Vulkan)); + ensure("unknown backends fail closed", + !legacyNormSpecShaderManifest(legacyNormSpecDiagnosticPipelineKey(), static_cast(255))); + const ShaderManifest unknown_backend = legacyNormSpecDiagnosticShaderManifest(static_cast(255)); + ensure("direct construction cannot turn an unknown backend into Vulkan", + unknown_backend.mBackend == static_cast(255) && !validShaderManifest(unknown_backend)); +} + +template<> +template<> +void shader_manifest_test_object::test<4>() +{ + const ShaderManifest canonical = legacyNormSpecDiagnosticShaderManifest(ShaderBackend::OpenGL); + ShaderManifest changed = canonical; + changed.mSourceUnits.push_back(changed.mSourceUnits.front()); + ensure("duplicate stage source units are structurally invalid", !validShaderManifest(changed)); + + changed = canonical; + std::swap(changed.mSourceUnits[2], changed.mSourceUnits[3]); + ensure("source link order is part of the exact recipe", + validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mSourceUnits[14].mShaderClass = 2; + ensure("resolved feature class is part of the exact recipe", + validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mDefines.push_back(changed.mDefines.front()); + ensure("duplicate visible defines are rejected", !validShaderManifest(changed)); + + changed = canonical; + changed.mVertexInputs[1].mLocation = changed.mVertexInputs[0].mLocation; + ensure("duplicate vertex locations are rejected", !validShaderManifest(changed)); + + changed = canonical; + changed.mSampledImages[1].mBinding = changed.mSampledImages[0].mBinding; + ensure("duplicate descriptor coordinates are rejected", !validShaderManifest(changed)); + + changed = canonical; + changed.mLogicalParameters[5].mVisibility = {}; + ensure("parameters require stage visibility", !validShaderManifest(changed)); + + changed = canonical; + changed.mLogicalParameters[5].mWordOffset++; + ensure("missing and overlapping parameter words are rejected", !validShaderManifest(changed)); + + changed = canonical; + changed.mFragmentOutputDeclarations[0].mExtraElementsInert = false; + ensure("extra backend declarations must be marked inert", !validShaderManifest(changed)); + + changed = canonical; + changed.mLinkedBlockBaggage[0].mActiveMembers.push_back("refBox"); + ensure("duplicate linked-block members are rejected", !validShaderManifest(changed)); +} + +template<> +template<> +void shader_manifest_test_object::test<5>() +{ + const ShaderManifest canonical = legacyNormSpecDiagnosticShaderManifest(ShaderBackend::Vulkan); + ShaderManifest changed = canonical; + changed.mSourceUnits.pop_back(); + ensure("missing included material math fails exact validation", + validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mInterstageVariables.erase(changed.mInterstageVariables.begin() + 2); + ensure("missing flat interstage data fails exact validation", + validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mParameterBlock->mVisibility.mFragment = false; + ensure("incorrect reflected block visibility fails exact validation", + validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + std::swap(changed.mDefines[0], changed.mDefines[1]); + ensure("define order is exact", validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mBackend = ShaderBackend::OpenGL; + ensure("backend identity is exact", validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mEntryPoints[0].mName = "vertexMain"; + ensure("entry-point spelling is exact", validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + std::swap(changed.mVertexInputs[0].mSemantic, changed.mVertexInputs[1].mSemantic); + changed.mVertexInputs[0].mFormat = VertexFormat::Float4; + ensure("vertex semantic and format mapping is exact", + validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mLogicalParameters[5].mOrigin = ShaderParameterOrigin::CopiedDraw; + ensure("parameter provenance is exact", validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mSampledImages[2].mBinding = 9; + ensure("descriptor coordinates are exact", validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + for (ShaderLogicalFragmentOutput& output : changed.mLogicalFragmentOutputs) + { + ++output.mLocation; + } + ++changed.mFragmentOutputDeclarations[0].mFirstLocation; + ensure("fragment output locations are exact", validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mFragmentOutputDeclarations[0].mElementCount = 4; + changed.mFragmentOutputDeclarations[0].mExtraElementsInert = true; + ensure("fragment declaration counts are exact", validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mParameterBlock->mSet = 1; + changed.mParameterBlock->mBinding = 0; + ensure("parameter blocks cannot collide with sampled images", !validShaderManifest(changed)); + + changed = canonical; + changed.mPushConstantRanges.push_back({ 0, 4, { true, false } }); + ensure("an extra push-constant range is not the proven recipe", + validShaderManifest(changed) && !validLegacyNormSpecDiagnosticShaderManifest(changed)); + changed.mPushConstantRanges[0].mByteSize = 0; + ensure("zero-sized push constants are structurally invalid", !validShaderManifest(changed)); + + changed = canonical; + changed.mFragmentOutputDeclarations[0].mElementCount = std::numeric_limits::max(); + changed.mFragmentOutputDeclarations[0].mFirstLocation = 1; + ensure("overflowing output declarations are rejected", !validShaderManifest(changed)); +} + +template<> +template<> +void shader_manifest_test_object::test<6>() +{ + const auto modern = legacyNormSpecShaderManifest(legacyNormSpecModernHDRPipelineKey(), ShaderBackend::Vulkan); + const auto compatibility = legacyNormSpecShaderManifest(legacyNormSpecCompatibilityPipelineKey(), ShaderBackend::Vulkan); + ensure("both production profiles resolve a Vulkan shader contract", modern.has_value() && compatibility.has_value()); + ensure("target formats do not fork the production shader contract", *modern == *compatibility); + + const ShaderManifest& manifest = *modern; + const ShaderManifest diagnostic = legacyNormSpecDiagnosticShaderManifest(ShaderBackend::Vulkan); + ensure("the production shader contract is structurally and exactly valid", + validShaderManifest(manifest) && validLegacyNormSpecProductionShaderManifest(manifest) && + !validLegacyNormSpecDiagnosticShaderManifest(manifest)); + ensure("the production semantic program variant is explicit", + manifest.mProgram.mName == LEGACY_NORMSPEC_PIPELINE_NAME && manifest.mProgram.mVariant == LEGACY_NORMSPEC_PRODUCTION_VARIANT); + ensure("production retains the proven source and vertex-stage contracts", + manifest.mSourceUnits == diagnostic.mSourceUnits && manifest.mVertexInputs == diagnostic.mVertexInputs && + manifest.mInterstageVariables == diagnostic.mInterstageVariables); + ensure("production retains the proven material resources", + manifest.mSampledImages == diagnostic.mSampledImages && manifest.mLogicalParameters == diagnostic.mLogicalParameters && + manifest.mParameterBlock == diagnostic.mParameterBlock && manifest.mLinkedBlockBaggage.empty() && + manifest.mPushConstantRanges.empty()); + + ensure("the ordered production compile and effective macro recipe is complete", + manifest.mDefines.size() == 19 && manifest.mDefines[0].mName == "LL_VULKAN_MATERIAL_PRODUCTION" && + manifest.mDefines[0].mVisibility.mVertex && !manifest.mDefines[0].mVisibility.mFragment && + manifest.mDefines[5].mName == "HAS_EMISSIVE" && manifest.mDefines[8].mName == "SPOT_SHADOW" && + manifest.mDefines[9].mName == "LL_VULKAN_MATERIAL_PRODUCTION" && !manifest.mDefines[9].mVisibility.mVertex && + manifest.mDefines[9].mVisibility.mFragment && manifest.mDefines[14].mName == "GBUFFER_FLAG_HAS_ATMOS" && + manifest.mDefines[15].mName == "HAS_EMISSIVE" && manifest.mDefines[16].mName == "HAS_SUN_SHADOW" && + manifest.mDefines[17].mName == "SUN_SHADOW" && manifest.mDefines[18].mName == "SPOT_SHADOW"); + ensure("production exposes a fourth logical emissive output", + manifest.mLogicalFragmentOutputs.size() == 4 && + manifest.mLogicalFragmentOutputs[3].mRole == ShaderFragmentOutputRole::EmissiveBuffer && + manifest.mLogicalFragmentOutputs[3].mLocation == 3 && manifest.mLogicalFragmentOutputs[3].mType == ShaderValueType::Float4); + ensure("production declares exactly four backend outputs", + manifest.mFragmentOutputDeclarations.size() == 1 && manifest.mFragmentOutputDeclarations[0].mFirstLocation == 0 && + manifest.mFragmentOutputDeclarations[0].mElementCount == 4 && + manifest.mFragmentOutputDeclarations[0].mLogicalElementCount == 4 && + !manifest.mFragmentOutputDeclarations[0].mExtraElementsInert); + + ensure("production profiles have no OpenGL shader contract", + !legacyNormSpecShaderManifest(legacyNormSpecModernHDRPipelineKey(), ShaderBackend::OpenGL) && + !legacyNormSpecShaderManifest(legacyNormSpecCompatibilityPipelineKey(), ShaderBackend::OpenGL)); + ensure("production lookup leaves the diagnostic factory exact", + diagnostic == legacyNormSpecDiagnosticShaderManifest(ShaderBackend::Vulkan) && + validLegacyNormSpecDiagnosticShaderManifest(diagnostic)); +} + +template<> +template<> +void shader_manifest_test_object::test<7>() +{ + const auto resolved = legacyNormSpecShaderManifest(legacyNormSpecModernHDRPipelineKey(), ShaderBackend::Vulkan); + ensure("the production mutation fixture resolves", resolved.has_value()); + const ShaderManifest canonical = *resolved; + ShaderManifest changed = canonical; + + std::swap(changed.mDefines[0], changed.mDefines[1]); + ensure("production compile-selector order is exact", + validShaderManifest(changed) && !validLegacyNormSpecProductionShaderManifest(changed)); + + changed = canonical; + changed.mDefines.erase(changed.mDefines.begin()); + ensure("the production compile selector is required", + validShaderManifest(changed) && !validLegacyNormSpecProductionShaderManifest(changed)); + + changed = canonical; + changed.mProgram.mVariant = LEGACY_NORMSPEC_DIAGNOSTIC_VARIANT; + ensure("production program identity is exact", + validShaderManifest(changed) && !validLegacyNormSpecProductionShaderManifest(changed) && + !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + std::swap(changed.mSourceUnits[0], changed.mSourceUnits[1]); + ensure("production source order is exact", validShaderManifest(changed) && !validLegacyNormSpecProductionShaderManifest(changed)); + + changed = canonical; + changed.mBackend = ShaderBackend::OpenGL; + ensure("production backend identity is exact", + validShaderManifest(changed) && !validLegacyNormSpecProductionShaderManifest(changed) && + !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mSampledImages[0].mBinding = 3; + ensure("production descriptor coordinates are exact", + validShaderManifest(changed) && !validLegacyNormSpecProductionShaderManifest(changed)); + + changed = canonical; + changed.mDefines[15].mValue = "0"; + changed.mProgram.mVariant = LEGACY_NORMSPEC_DIAGNOSTIC_VARIANT; + ensure("mixed production macros and diagnostic program identity match no exact recipe", + validShaderManifest(changed) && !validLegacyNormSpecProductionShaderManifest(changed) && + !validLegacyNormSpecDiagnosticShaderManifest(changed)); + + changed = canonical; + changed.mLogicalFragmentOutputs.pop_back(); + changed.mFragmentOutputDeclarations[0].mElementCount = 3; + changed.mFragmentOutputDeclarations[0].mLogicalElementCount = 3; + ensure("a three-output production variant is not canonical", + validShaderManifest(changed) && !validLegacyNormSpecProductionShaderManifest(changed)); + + changed = canonical; + changed.mLogicalFragmentOutputs[3].mRole = ShaderFragmentOutputRole::NormalEnvironment; + ensure("duplicate logical output roles are structurally invalid", !validShaderManifest(changed)); + + changed = canonical; + changed.mLogicalFragmentOutputs[3].mRole = static_cast(255); + ensure("unknown logical output roles are structurally invalid", !validShaderManifest(changed)); + + changed = canonical; + changed.mLogicalFragmentOutputs[3].mLocation = 4; + changed.mFragmentOutputDeclarations = { { "frag_data", 0, 3, ShaderValueType::Float4, 3, false }, + { "emissive_output", 4, 1, ShaderValueType::Float4, 1, false } }; + ensure("the production output declaration shape is exact", + validShaderManifest(changed) && !validLegacyNormSpecProductionShaderManifest(changed)); + + LegacyNormSpecPipelineKey mixed_key = legacyNormSpecModernHDRPipelineKey(); + mixed_key.mTargetProfile = LegacyNormSpecTargetProfile::Compatibility; + ensure("mixed production profile dimensions do not resolve", !legacyNormSpecShaderManifest(mixed_key, ShaderBackend::Vulkan)); + + mixed_key = legacyNormSpecCompatibilityPipelineKey(); + mixed_key.mShaderVariant.mEmissive = LegacyNormSpecEmissive::Disabled; + ensure("mixed production shader dimensions do not resolve", !legacyNormSpecShaderManifest(mixed_key, ShaderBackend::Vulkan)); +} + +} // namespace tut diff --git a/indra/llrender/tests/lltextureuploadcontract_test.cpp b/indra/llrender/tests/lltextureuploadcontract_test.cpp new file mode 100644 index 00000000000..2eba81834e2 --- /dev/null +++ b/indra/llrender/tests/lltextureuploadcontract_test.cpp @@ -0,0 +1,232 @@ +/** + * @file lltextureuploadcontract_test.cpp + * @brief Tests for the backend-neutral streaming-upload packet. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "lltextureuploadcontract.h" +#include "lltut.h" + +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; + +StreamingUploadInputs inputs() +{ + StreamingUploadInputs result; + result.mFrame = 7; + result.mPixels.reserve(TEXTURE_UPLOAD_SOURCE_BYTE_COUNT); + for (std::size_t byte = 0; byte < TEXTURE_UPLOAD_SOURCE_BYTE_COUNT; ++byte) + { + result.mPixels.push_back(static_cast((byte * 37 + 19) % 256)); + } + return result; +} + +FrameSnapshot frame() +{ + return *buildStreamingUploadFrame(inputs()); +} + +} // namespace + +namespace tut +{ + +struct texture_upload_contract_test +{ +}; + +using texture_upload_contract_test_group = test_group; +using texture_upload_contract_test_object = texture_upload_contract_test_group::object; +texture_upload_contract_test_group texture_upload_contract_tests("texture upload contract"); + +template<> +template<> +void texture_upload_contract_test_object::test<1>() +{ + StreamingUploadInputs original = inputs(); + const std::uint8_t first = original.mPixels.front(); + auto built = buildStreamingUploadFrame(original); + ensure("canonical streaming upload builds", built.has_value()); + original.mPixels.front() ^= 0xffU; + + auto decoded = decodeStreamingUploadFrame(*built); + ensure("canonical streaming upload decodes", decoded.has_value()); + ensure("builder owns source pixels independently of the caller", + decoded->mPixels.front() == first && decoded->mPixels != original.mPixels); + decoded->mPixels.front() ^= 0xffU; + auto decoded_again = decodeStreamingUploadFrame(*built); + ensure("decoder returns its own pixel vector", decoded_again && decoded_again->mPixels.front() == first); + ensure("request preserves padded top-left rows", + decoded_again->mRowOrigin == RowOrigin::TopLeft && decoded_again->mRowPitch == TEXTURE_UPLOAD_ROW_PITCH && + decoded_again->mPixels.size() == TEXTURE_UPLOAD_SOURCE_BYTE_COUNT); + ensure("old and replacement generations share one logical index", + decoded_again->mHandles.mOldImage.mIndex == decoded_again->mHandles.mReplacementImage.mIndex && + decoded_again->mHandles.mReplacementImage.mGeneration == decoded_again->mHandles.mOldImage.mGeneration + 1); + ensure("only the old generation retires after this frame", + built->mReleases.size() == 1 && std::holds_alternative(built->mReleases[0].mResource) && + std::get(built->mReleases[0].mResource) == decoded_again->mHandles.mOldImage && + built->mReleases[0].mFrame == built->mFrame); +} + +template<> +template<> +void texture_upload_contract_test_object::test<2>() +{ + StreamingUploadInputs value = inputs(); + value.mFrame = 0; + ensure("zero frame is rejected", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mHandles.mReplacementImage.mGeneration += 1; + ensure("non-consecutive replacement generation is rejected", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mHandles.mOutput.mIndex = value.mHandles.mOldImage.mIndex; + value.mHandles.mOutput.mGeneration += 5; + ensure("output cannot alias the streamed image index", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mRevision = TEXTURE_UPLOAD_REVISION - 1; + ensure("the frozen revision is rejected when changed", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mSubresource.mMipLevel = 1; + ensure("only base mip upload is accepted", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mOffset.mX = 1; + ensure("partial destination offsets are outside this slice", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mExtent.mWidth -= 1; + ensure("partial upload extents are outside this slice", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mLogicalExtent.mHeight >>= 1; + ensure("logical extent is frozen", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mResidentDiscard = 1; + ensure("resident discard is frozen", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mSourceFormat = PixelFormat::RGBA8Srgb; + ensure("source format is frozen", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mRowPitch -= 1; + ensure("padded row pitch is frozen", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mRowOrigin = RowOrigin::BottomLeft; + ensure("source row origin is frozen", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mMipGeneration = MipGeneration::Disabled; + ensure("remaining mips must be generated", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mPixels.pop_back(); + ensure("short owned pixel range is rejected", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mBefore = ImageState::ShaderRead; + ensure("before state is frozen", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mDuring = ImageState::ColorAttachment; + ensure("during state is frozen", !buildStreamingUploadFrame(value)); + + value = inputs(); + value.mAfter = ImageState::ColorAttachment; + ensure("after state is frozen", !buildStreamingUploadFrame(value)); +} + +template<> +template<> +void texture_upload_contract_test_object::test<3>() +{ + FrameSnapshot packet = frame(); + std::swap(packet.mImages[0], packet.mImages[1]); + ensure("resource declaration order is canonical", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + packet.mImages[1].mMipLevels = 2; + ensure("replacement mip count is canonical", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + packet.mImages[2].mLifetime = ResourceLifetime::Persistent; + ensure("sample target ownership is canonical", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + packet.mSamplers[0].mAddressU = AddressMode::Repeat; + ensure("sampler address state is canonical", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + packet.mPipelines[0].mProgram.mName = "other"; + ensure("program identity is canonical", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + packet.mPasses[0].mViewport.mWidth -= 1.f; + ensure("sample viewport is canonical", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + std::get(packet.mPasses[0].mDraws[0]).mVertexCount = 4; + ensure("sample draw shape is canonical", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + packet.mReleases.clear(); + ensure("old generation release is required", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + packet.mReleases[0].mResource = packet.mImages[1].mHandle; + ensure("replacement generation cannot be released", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + ++packet.mReleases[0].mFrame; + ensure("release frame is exact", !decodeStreamingUploadFrame(packet)); +} + +template<> +template<> +void texture_upload_contract_test_object::test<4>() +{ + FrameSnapshot packet = frame(); + packet.mUploads[0].mPixels.mStorage.reset(); + ensure("unowned upload storage is rejected", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + packet.mUploads[0].mPixels.mOffset = 1; + packet.mUploads[0].mPixels.mSize -= 1; + ensure("non-canonical byte range offset is rejected", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + auto larger = std::make_shared>(TEXTURE_UPLOAD_SOURCE_BYTE_COUNT + 1, 0); + packet.mUploads[0].mPixels = { larger, 0, TEXTURE_UPLOAD_SOURCE_BYTE_COUNT }; + ensure("extra caller storage cannot hide outside the packet range", !decodeStreamingUploadFrame(packet)); + + packet = frame(); + packet.mUploads[0].mPixels.mSize -= 1; + ensure("short packet range is rejected", !decodeStreamingUploadFrame(packet)); +} + +} // namespace tut diff --git a/indra/llrender/tests/lltextureuploaddiagnostic_test.cpp b/indra/llrender/tests/lltextureuploaddiagnostic_test.cpp new file mode 100644 index 00000000000..c3f85905b01 --- /dev/null +++ b/indra/llrender/tests/lltextureuploaddiagnostic_test.cpp @@ -0,0 +1,397 @@ +/** + * @file lltextureuploaddiagnostic_test.cpp + * @brief Tests for the streamed texture upload diagnostic boundary. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "lltextureuploaddiagnostic.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; + +TextureUploadArtifact completeArtifact() +{ + TextureUploadArtifact artifact = makeTextureUploadArtifact(); + artifact.mPriorRevision = TEXTURE_UPLOAD_PRIOR_REVISION; + artifact.mRevision = TEXTURE_UPLOAD_REVISION; + artifact.mCompletionCount = 1; + artifact.mCompletedDestination = ImageHandle{ 11, 2 }; + artifact.mCompletedRevision = TEXTURE_UPLOAD_REVISION; + artifact.mCompletedFrame = TEXTURE_UPLOAD_DIAGNOSTIC_FRAME; + artifact.mRetirementCount = 1; + artifact.mRetiredResource = ImageHandle{ 11, 1 }; + artifact.mRetirementFrame = TEXTURE_UPLOAD_DIAGNOSTIC_FRAME; + artifact.mOldResolvableBefore = true; + artifact.mOldResolvableAfter = false; + artifact.mReplacementResolvableAfter = true; + for (std::size_t mip = 0; mip < artifact.mMipRGBA8.size(); ++mip) + { + artifact.mMipRGBA8[mip].reserve(TEXTURE_UPLOAD_MIP_BYTE_SIZES[mip]); + for (std::size_t byte = 0; byte < TEXTURE_UPLOAD_MIP_BYTE_SIZES[mip]; ++byte) + { + artifact.mMipRGBA8[mip].push_back( + static_cast(1 + (mip * 83 + byte * 17 + 31) % 251)); + } + } + artifact.mSampledRGBA8.reserve(TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT); + for (std::size_t byte = 0; byte < TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT; ++byte) + { + artifact.mSampledRGBA8.push_back(static_cast(1 + (byte * 43 + 109) % 251)); + } + return artifact; +} + +void refreshArtifactChecksum(std::vector& bytes) +{ + constexpr std::uint64_t FNV_OFFSET_BASIS = 14695981039346656037ULL; + constexpr std::uint64_t FNV_PRIME = 1099511628211ULL; + const std::size_t checksum_offset = bytes.size() - sizeof(std::uint64_t); + std::uint64_t checksum = FNV_OFFSET_BASIS; + for (std::size_t index = 0; index < checksum_offset; ++index) + { + checksum ^= bytes[index]; + checksum *= FNV_PRIME; + } + for (std::size_t byte = 0; byte < sizeof(checksum); ++byte) + { + bytes[checksum_offset + byte] = static_cast(checksum >> ((7 - byte) * 8)); + } +} + +std::filesystem::path temporaryArtifactPath() +{ + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + return std::filesystem::temp_directory_path() / + ("lltextureuploaddiagnostic-" + std::to_string(suffix) + ".bin"); +} + +std::uint8_t sourceComponent(const TextureUploadFixture& fixture, std::size_t top_row, std::size_t x, + std::size_t channel) +{ + return fixture.mSourceRGBA8[top_row * TEXTURE_UPLOAD_ROW_PITCH + x * TEXTURE_UPLOAD_CHANNELS + channel]; +} + +bool sameExtent(Extent2D extent, std::uint32_t width, std::uint32_t height) +{ + return extent.mWidth == width && extent.mHeight == height; +} + +} // namespace + +namespace tut +{ + +struct texture_upload_diagnostic_test +{ +}; + +using texture_upload_diagnostic_test_group = test_group; +using texture_upload_diagnostic_test_object = texture_upload_diagnostic_test_group::object; +texture_upload_diagnostic_test_group texture_upload_diagnostic_tests("texture upload diagnostic"); + +template<> +template<> +void texture_upload_diagnostic_test_object::test<1>() +{ + const TextureUploadFixture fixture = makeTextureUploadFixture(); + ensure("resident, logical, and sample extents are asymmetric", + sameExtent(fixture.mResidentExtent, 8, 4) && sameExtent(fixture.mLogicalExtent, 32, 16) && + sameExtent(fixture.mOutputExtent, 4, 2)); + ensure("source has padded top-left rows", + fixture.mSourceRowOrigin == RowOrigin::TopLeft && fixture.mSourceRowPitch == 36 && + fixture.mSourceRGBA8.size() == 144); + ensure("source corners and channels are observably distinct", + sourceComponent(fixture, 0, 0, 0) != sourceComponent(fixture, 0, 7, 0) && + sourceComponent(fixture, 0, 0, 0) != sourceComponent(fixture, 3, 0, 0) && + sourceComponent(fixture, 0, 0, 0) != sourceComponent(fixture, 0, 0, 1)); + + bool poison_is_exact = true; + bool averages_are_integral = true; + for (std::size_t top_row = 0; top_row < TEXTURE_UPLOAD_RESIDENT_HEIGHT; ++top_row) + { + const std::size_t row_start = top_row * TEXTURE_UPLOAD_ROW_PITCH; + for (std::size_t padding = 0; padding < 4; ++padding) + { + poison_is_exact = poison_is_exact && + fixture.mSourceRGBA8[row_start + 32 + padding] == 0xf0U + top_row * 4 + padding; + } + } + for (std::size_t top_row = 0; top_row < TEXTURE_UPLOAD_RESIDENT_HEIGHT; top_row += 2) + { + for (std::size_t x = 0; x < TEXTURE_UPLOAD_RESIDENT_WIDTH; x += 2) + { + for (std::size_t channel = 0; channel < TEXTURE_UPLOAD_CHANNELS; ++channel) + { + const unsigned sum = sourceComponent(fixture, top_row, x, channel) + + sourceComponent(fixture, top_row, x + 1, channel) + + sourceComponent(fixture, top_row + 1, x, channel) + + sourceComponent(fixture, top_row + 1, x + 1, channel); + averages_are_integral = averages_are_integral && sum % 4 == 0; + } + } + } + ensure("every row ends in four distinct poison bytes", poison_is_exact); + ensure("first generated mip uses exact integer 2 by 2 averages", averages_are_integral); + ensure("screen triangle has the exact 16-byte vertex stride", + fixture.mScreenTriangle == std::array{ -1.f, 1.f, 0.f, 0.f, + -1.f, -3.f, 0.f, 0.f, + 3.f, 1.f, 0.f, 0.f }); + ensure("old, replacement sentinel, and output sentinel storage is complete", + fixture.mOldMipRGBA8.size() == 168 && fixture.mReplacementSentinelMipRGBA8.size() == 168 && + fixture.mOutputSentinelRGBA8.size() == 32); + ensure("initial generations and outputs are observably distinct", + fixture.mOldMipRGBA8[0] != fixture.mReplacementSentinelMipRGBA8[0] && + fixture.mReplacementSentinelMipRGBA8[0] != fixture.mSourceRGBA8[0] && + fixture.mOutputSentinelRGBA8[0] != fixture.mReplacementSentinelMipRGBA8[0]); + + ensure_equals("fixture drift requires an explicit fixture revision", textureUploadFixtureFingerprint(), + std::uint64_t{ 0x7f76518103e7019eULL }); + + TextureUploadCase diagnostic_case = makeTextureUploadCase(); + auto decoded = decodeStreamingUploadFrame(diagnostic_case.mFrame); + ensure("fixture produces one canonical owned frame", decoded.has_value()); + ensure("case freezes the consecutive revisions and discard geometry", + diagnostic_case.mPriorRevision == 22 && decoded->mRevision == 23 && decoded->mResidentDiscard == 2 && + sameExtent(decoded->mLogicalExtent, 32, 16)); + ensure("case owns the entire padded source", + decoded->mPixels == std::vector(fixture.mSourceRGBA8.begin(), fixture.mSourceRGBA8.end())); + diagnostic_case.mInputs.mPixels[0] ^= 0xffU; + auto decoded_again = decodeStreamingUploadFrame(diagnostic_case.mFrame); + ensure("mutating case inputs cannot mutate frame upload storage", + decoded_again && decoded_again->mPixels[0] == fixture.mSourceRGBA8[0]); +} + +template<> +template<> +void texture_upload_diagnostic_test_object::test<2>() +{ + const TextureUploadArtifact unobserved = makeTextureUploadArtifact(); + ensure("new artifacts contain no implicit upload evidence", + unobserved.mPriorRevision == 0 && unobserved.mRevision == 0 && unobserved.mCompletionCount == 0 && + unobserved.mCompletedDestination == ImageHandle{} && unobserved.mCompletedRevision == 0 && + unobserved.mCompletedFrame == 0 && unobserved.mRetirementCount == 0 && + unobserved.mRetiredResource == ImageHandle{} && unobserved.mRetirementFrame == 0 && + !unobserved.mOldResolvableBefore && !unobserved.mOldResolvableAfter && + !unobserved.mReplacementResolvableAfter); + const TextureUploadArtifact artifact = completeArtifact(); + std::string error; + std::vector first_encoding; + std::vector second_encoding; + ensure("complete texture upload artifact validates", validateTextureUploadArtifact(artifact, &error)); + ensure("complete texture upload artifact encodes", encodeTextureUploadArtifact(artifact, first_encoding, &error)); + ensure("artifact schema has a fixed byte count", first_encoding.size() == TEXTURE_UPLOAD_ARTIFACT_BYTE_SIZE); + ensure("artifact encoding is deterministic", + encodeTextureUploadArtifact(artifact, second_encoding, &error) && first_encoding == second_encoding); + + TextureUploadArtifact decoded; + ensure("canonical artifact decodes", decodeTextureUploadArtifact(first_encoding, decoded, &error)); + ensure("artifact round trip preserves pixels and lifecycle evidence", decoded == artifact); + + const std::filesystem::path path = temporaryArtifactPath(); + std::filesystem::path predictable_temporary = path; + predictable_temporary += ".tmp"; + std::error_code cleanup_error; + std::filesystem::remove(path, cleanup_error); + std::filesystem::remove_all(predictable_temporary, cleanup_error); + ensure("predictable temporary sibling can already exist", std::filesystem::create_directory(predictable_temporary)); + ensure("writer publishes through an unpredictable sibling", writeTextureUploadArtifact(path, artifact, &error)); + ensure("writer leaves the predictable sibling untouched", + std::filesystem::exists(path) && std::filesystem::is_directory(predictable_temporary)); + bool unique_temporary_remains = false; + const std::string temporary_prefix = path.filename().string() + ".tmp."; + for (const std::filesystem::directory_entry& entry : std::filesystem::directory_iterator(path.parent_path())) + { + unique_temporary_remains = unique_temporary_remains || entry.path().filename().string().starts_with(temporary_prefix); + } + ensure("published artifact has no unique temporary sibling", !unique_temporary_remains); + + TextureUploadArtifact from_file; + ensure("published artifact reads back", readTextureUploadArtifact(path, from_file, &error)); + ensure("file round trip preserves exact bytes and evidence", from_file == artifact); + TextureUploadArtifact replacement = artifact; + replacement.mSampledRGBA8[0] ^= 1U; + ensure("atomic writer refuses to replace an existing artifact", !writeTextureUploadArtifact(path, replacement, &error)); + ensure("failed replacement leaves the published artifact unchanged", + readTextureUploadArtifact(path, from_file, &error) && from_file == artifact); + std::filesystem::remove(path, cleanup_error); + std::filesystem::remove_all(predictable_temporary, cleanup_error); +} + +template<> +template<> +void texture_upload_diagnostic_test_object::test<3>() +{ + const TextureUploadArtifact artifact = completeArtifact(); + std::string error; + std::vector encoded; + ensure("baseline texture upload artifact encodes", encodeTextureUploadArtifact(artifact, encoded, &error)); + const std::vector baseline = encoded; + + TextureUploadArtifact invalid = artifact; + invalid.mMipRGBA8[1].pop_back(); + ensure("short generated mip is rejected", !validateTextureUploadArtifact(invalid, &error)); + + invalid = artifact; + invalid.mSampledRGBA8.pop_back(); + ensure("short sampled output is rejected", !validateTextureUploadArtifact(invalid, &error)); + + invalid = artifact; + invalid.mCompletionCount = 2; + ensure("duplicate completion evidence is rejected", !validateTextureUploadArtifact(invalid, &error)); + + invalid = artifact; + invalid.mCompletedRevision = TEXTURE_UPLOAD_PRIOR_REVISION; + ensure("completion for a stale revision is rejected", !validateTextureUploadArtifact(invalid, &error)); + + invalid = artifact; + invalid.mRetirementCount = 0; + ensure("missing retirement evidence is rejected", !validateTextureUploadArtifact(invalid, &error)); + + invalid = artifact; + invalid.mRetiredResource.mGeneration = 2; + ensure("retiring the replacement generation is rejected", !validateTextureUploadArtifact(invalid, &error)); + + invalid = artifact; + ++invalid.mRetirementFrame; + ensure("late retirement evidence is rejected", !validateTextureUploadArtifact(invalid, &error)); + + invalid = artifact; + invalid.mOldResolvableAfter = true; + invalid.mReplacementResolvableAfter = false; + ensure("non-canonical post-state evidence is rejected", !validateTextureUploadArtifact(invalid, &error)); + + TextureUploadArtifact decoded; + std::vector corrupt = baseline; + corrupt[0] ^= 0xffU; + refreshArtifactChecksum(corrupt); + ensure("bad artifact magic is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[11] = 3; + refreshArtifactChecksum(corrupt); + ensure("unknown artifact schema is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[23] ^= 1U; + refreshArtifactChecksum(corrupt); + ensure("wrong fixture fingerprint is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[47] = TEXTURE_UPLOAD_PRIOR_REVISION; + refreshArtifactChecksum(corrupt); + ensure("stale wire revision is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[119] = 2; + refreshArtifactChecksum(corrupt); + ensure("wrong source row origin is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[123] = 1; + refreshArtifactChecksum(corrupt); + ensure("wrong artifact row origin is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[127] = 32; + refreshArtifactChecksum(corrupt); + ensure("tight wire row pitch is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[131] = 0; + refreshArtifactChecksum(corrupt); + ensure("disabled wire mip generation is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[167] = 2; + refreshArtifactChecksum(corrupt); + ensure("duplicate wire completion evidence is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[215] = 0; + refreshArtifactChecksum(corrupt); + ensure("missing old-before state is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[231] = 7; + refreshArtifactChecksum(corrupt); + ensure("wrong base mip width is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[239] = 127; + refreshArtifactChecksum(corrupt); + ensure("wrong base mip byte count is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + const std::size_t checksum_offset = baseline.size() - sizeof(std::uint64_t); + const std::size_t sample_offset = checksum_offset - TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT; + corrupt = baseline; + corrupt[sample_offset - 1] ^= 1U; + ensure("mip payload corruption is rejected by its checksum", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + ensure_equals("mip corruption reports the integrity failure", error, std::string("artifact checksum is invalid")); + + corrupt = baseline; + corrupt[sample_offset] ^= 1U; + ensure("sample payload corruption is rejected by its checksum", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + ensure_equals("sample corruption reports the integrity failure", error, std::string("artifact checksum is invalid")); + + corrupt = baseline; + corrupt[checksum_offset] ^= 1U; + ensure("checksum corruption is rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + ensure_equals("checksum corruption reports the integrity failure", error, std::string("artifact checksum is invalid")); + + corrupt = baseline; + corrupt.pop_back(); + ensure("truncated artifacts are rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt.push_back(0); + ensure("trailing artifact bytes are rejected", !decodeTextureUploadArtifact(corrupt, decoded, &error)); +} + +template<> +template<> +void texture_upload_diagnostic_test_object::test<4>() +{ + const TextureUploadArtifact reference = completeArtifact(); + TextureUploadArtifact candidate = reference; + + TextureUploadComparisonStats stats = compareTextureUploadArtifacts(reference, candidate); + ensure("identical artifacts match exactly", stats.mComparable && stats.mMatch && stats.mMismatchCount == 0); + ensure("comparison reports every mip and sample byte", + stats.mComparedMipBytes == 168 && stats.mComparedSampleBytes == 32); + + candidate.mMipRGBA8[1][3] ^= 1U; + candidate.mSampledRGBA8[5] ^= 1U; + stats = compareTextureUploadArtifacts(reference, candidate); + ensure("every distinct byte mismatches at zero tolerance", + stats.mComparable && !stats.mMatch && stats.mMismatchCount == 2); + ensure("first mismatch identifies the mip, byte, and exact codes", + stats.mFirstMismatchPlane == 2 && stats.mFirstMismatchByte == 3 && + stats.mFirstReference == reference.mMipRGBA8[1][3] && + stats.mFirstCandidate == candidate.mMipRGBA8[1][3]); + + candidate.mMipRGBA8[0].pop_back(); + stats = compareTextureUploadArtifacts(reference, candidate); + ensure("invalid candidates fail comparison preflight", !stats.mComparable && !stats.mMatch && !stats.mError.empty()); +} + +} // namespace tut diff --git a/indra/llrender/tests/lltonemapdiagnostic_test.cpp b/indra/llrender/tests/lltonemapdiagnostic_test.cpp new file mode 100644 index 00000000000..ce785328bd8 --- /dev/null +++ b/indra/llrender/tests/lltonemapdiagnostic_test.cpp @@ -0,0 +1,271 @@ +/** + * @file lltonemapdiagnostic_test.cpp + * @brief Tests for the cross-process tonemap diagnostic boundary. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "lltonemapdiagnostic.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; + +TonemapArtifact completeArtifact(float value = 0.f) +{ + TonemapArtifact artifact = makeTonemapArtifact(); + for (TonemapArtifactCase& artifact_case : artifact.mCases) + { + artifact_case.mPixels.assign(TONEMAP_DIAGNOSTIC_COMPONENT_COUNT, value); + } + return artifact; +} + +std::filesystem::path temporaryArtifactPath() +{ + const auto suffix = std::chrono::steady_clock::now().time_since_epoch().count(); + return std::filesystem::temp_directory_path() / + ("lltonemapdiagnostic-" + std::to_string(suffix) + ".bin"); +} + +} + +namespace tut +{ + +struct tonemap_diagnostic_test +{ +}; + +using tonemap_diagnostic_test_group = test_group; +using tonemap_diagnostic_test_object = tonemap_diagnostic_test_group::object; +tonemap_diagnostic_test_group tonemap_diagnostic_tests("tonemap diagnostic"); + +template<> +template<> +void tonemap_diagnostic_test_object::test<1>() +{ + const TonemapFixture fixture = makeTonemapFixture(); + ensure("fixture is 8 by 8", fixture.mExtent.mWidth == 8 && fixture.mExtent.mHeight == 8); + ensure("fixture rows start at the bottom", fixture.mRowOrigin == RowOrigin::BottomLeft); + ensure("fixture has one RGBA half value per source component", + fixture.mSceneRGBA16F.size() == TONEMAP_DIAGNOSTIC_COMPONENT_COUNT); + ensure("screen triangle is exactly 48 bytes", sizeof(fixture.mScreenTriangle) == 48); + ensure("screen triangle includes deterministic stride padding", + fixture.mScreenTriangle == std::array{ -1.f, 1.f, 0.f, 0.f, + -1.f, -3.f, 0.f, 0.f, + 3.f, 1.f, 0.f, 0.f }); + + ensure("known half values use IEEE encodings", floatToHalfBits(1.f) == 0x3c00U && + floatToHalfBits(-2.f) == 0xc000U && + halfBitsToFloat(0x0001U) == std::ldexp(1.f, -24)); + bool finite_half_values_round_trip = true; + for (std::uint32_t bits = 0; bits <= std::numeric_limits::max(); ++bits) + { + if ((bits & 0x7c00U) != 0x7c00U && + floatToHalfBits(halfBitsToFloat(static_cast(bits))) != bits) + { + finite_half_values_round_trip = false; + break; + } + } + ensure("all finite half encodings round trip", finite_half_values_round_trip); + ensure("half conversion rounds ties to even", + floatToHalfBits(1.f + std::ldexp(1.f, -11)) == 0x3c00U && + floatToHalfBits(1.f + 3.f * std::ldexp(1.f, -11)) == 0x3c02U); + ensure("source starts with the Stage 10 sequence", + fixture.mSceneRGBA16F[0] == floatToHalfBits(0.f) && + fixture.mSceneRGBA16F[1] == floatToHalfBits(0.04f * 0.75f) && + fixture.mSceneRGBA16F[2] == floatToHalfBits(0.08f * 1.25f) && + fixture.mSceneRGBA16F[3] == floatToHalfBits(0.f)); + ensure("exposure is the Stage 10 value", fixture.mExposureR16F == 0x3acdU); + ensure_equals("fixture fingerprint changes only with an explicit fixture revision", + tonemapFixtureFingerprint(), std::uint64_t{ 0xb5b53dcc766cd299ULL }); + + const TonemapCases cases = makeTonemapCases(); + ensure("fixture has the canonical case count", cases.size() == 24); + for (std::size_t offset = 0; offset < cases.size(); ++offset) + { + const TonemapCase& diagnostic_case = cases[offset]; + ensure("case indices and frame numbers are one based", + diagnostic_case.mKey.mIndex == offset + 1 && diagnostic_case.mInputs.mFrame == offset + 1 && + diagnostic_case.mFrame.mFrame == offset + 1); + auto decoded = decodeTonemapFrame(diagnostic_case.mFrame); + ensure("each case owns a canonical frame", decoded.has_value()); + ensure("case metadata agrees with its frame", + decoded->mDestinationFormat == diagnostic_case.mKey.mDestinationFormat && + decoded->mVariant == diagnostic_case.mKey.mVariant && + decoded->mParameters.mTonemapType == diagnostic_case.mKey.mTonemapType); + } + ensure("formats are the outer case dimension", + cases.front().mKey.mDestinationFormat == PixelFormat::RGBA8Unorm && + cases[11].mKey.mDestinationFormat == PixelFormat::RGBA8Unorm && + cases[12].mKey.mDestinationFormat == PixelFormat::RGBA16Float && + cases.back().mKey.mDestinationFormat == PixelFormat::RGBA16Float); +} + +template<> +template<> +void tonemap_diagnostic_test_object::test<2>() +{ + TonemapArtifact artifact = makeTonemapArtifact(); + for (TonemapArtifactCase& artifact_case : artifact.mCases) + { + artifact_case.mPixels.reserve(TONEMAP_DIAGNOSTIC_COMPONENT_COUNT); + for (std::size_t component = 0; component < TONEMAP_DIAGNOSTIC_COMPONENT_COUNT; ++component) + { + if (artifact_case.mKey.mDestinationFormat == PixelFormat::RGBA8Unorm) + { + const std::uint32_t code = static_cast((artifact_case.mKey.mIndex + component) % 256); + artifact_case.mPixels.push_back(static_cast(code) / 255.f); + } + else + { + const int numerator = static_cast(component % 33) - 16; + artifact_case.mPixels.push_back(halfBitsToFloat(floatToHalfBits(static_cast(numerator) / 16.f))); + } + } + } + + std::string error; + std::vector first_encoding; + std::vector second_encoding; + ensure("complete artifact validates", validateTonemapArtifact(artifact, &error)); + ensure("complete artifact encodes", encodeTonemapArtifact(artifact, first_encoding, &error)); + ensure("the schema has a fixed byte count", first_encoding.size() == 25676); + ensure("encoding is deterministic", encodeTonemapArtifact(artifact, second_encoding, &error) && + first_encoding == second_encoding); + + TonemapArtifact decoded; + ensure("canonical artifact decodes", decodeTonemapArtifact(first_encoding, decoded, &error)); + ensure("artifact round trip preserves metadata and pixels", decoded == artifact); + + const std::filesystem::path path = temporaryArtifactPath(); + std::filesystem::path temporary = path; + temporary += ".tmp"; + std::error_code cleanup_error; + std::filesystem::remove(path, cleanup_error); + std::filesystem::remove_all(temporary, cleanup_error); + ensure("fixed temporary sibling can already exist", std::filesystem::create_directory(temporary)); + ensure("artifact publishes through a unique sibling temporary file", writeTonemapArtifact(path, artifact, &error)); + ensure("writer does not touch a predictable temporary sibling", + std::filesystem::exists(path) && std::filesystem::is_directory(temporary)); + bool unique_temporary_remains = false; + const std::string temporary_prefix = path.filename().string() + ".tmp."; + for (const std::filesystem::directory_entry& entry : std::filesystem::directory_iterator(path.parent_path())) + { + unique_temporary_remains = unique_temporary_remains || + entry.path().filename().string().starts_with(temporary_prefix); + } + ensure("published artifact has no unique temporary sibling", !unique_temporary_remains); + TonemapArtifact from_file; + ensure("published artifact reads back", readTonemapArtifact(path, from_file, &error)); + ensure("file round trip preserves the artifact", from_file == artifact); + ensure("atomic writer refuses to replace an existing artifact", !writeTonemapArtifact(path, artifact, &error)); + std::filesystem::remove(path, cleanup_error); + std::filesystem::remove_all(temporary, cleanup_error); +} + +template<> +template<> +void tonemap_diagnostic_test_object::test<3>() +{ + TonemapArtifact artifact = completeArtifact(); + std::string error; + std::vector encoded; + ensure("baseline artifact encodes", encodeTonemapArtifact(artifact, encoded, &error)); + const std::vector baseline = encoded; + + TonemapArtifact invalid = artifact; + invalid.mCases.pop_back(); + ensure("missing cases are rejected", !validateTonemapArtifact(invalid, &error)); + + invalid = artifact; + invalid.mCases[0].mKey.mIndex = 2; + ensure("out of order metadata is rejected", !encodeTonemapArtifact(invalid, encoded, &error)); + + invalid = artifact; + invalid.mCases[0].mPixels[0] = std::numeric_limits::infinity(); + ensure("non-finite pixels are rejected before encoding", !validateTonemapArtifact(invalid, &error)); + + invalid = artifact; + invalid.mCases[0].mPixels[0] = 0.5f; + ensure("values not representable by RGBA8 are rejected", !validateTonemapArtifact(invalid, &error)); + + TonemapArtifact decoded; + std::vector corrupt = baseline; + corrupt[0] ^= 0xffU; + ensure("bad magic is rejected", !decodeTonemapArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[11] = 2; + ensure("unknown schema version is rejected", !decodeTonemapArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[47] = 2; + ensure("non-canonical case index is rejected", !decodeTonemapArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt[88] = 0x7fU; + corrupt[89] = 0x80U; + corrupt[90] = 0; + corrupt[91] = 0; + ensure("non-finite wire pixels are rejected", !decodeTonemapArtifact(corrupt, decoded, &error)); + + corrupt = baseline; + corrupt.push_back(0); + ensure("trailing bytes are rejected", !decodeTonemapArtifact(corrupt, decoded, &error)); +} + +template<> +template<> +void tonemap_diagnostic_test_object::test<4>() +{ + const TonemapArtifact reference = completeArtifact(); + TonemapArtifact candidate = reference; + candidate.mCases[0].mPixels[0] = 1.f / 255.f; + candidate.mCases[12].mPixels[0] = 2.f / 1024.f; + + TonemapComparisonStats stats = compareTonemapArtifacts(reference, candidate); + ensure("one RGBA8 code and two half steps are within tolerance", + stats.mComparable && stats.mMatch && stats.mMismatchCount == 0); + ensure("comparison reports its full scope", + stats.mComparedCases == TONEMAP_DIAGNOSTIC_CASE_COUNT && + stats.mComparedComponents == TONEMAP_DIAGNOSTIC_CASE_COUNT * TONEMAP_DIAGNOSTIC_COMPONENT_COUNT); + + candidate.mCases[0].mPixels[1] = 2.f / 255.f; + candidate.mCases[12].mPixels[1] = 3.f / 1024.f; + stats = compareTonemapArtifacts(reference, candidate); + ensure("values beyond each format tolerance mismatch", + stats.mComparable && !stats.mMatch && stats.mMismatchCount == 2); + ensure("first mismatch identifies case, pixel, and channel", + stats.mFirstMismatchCase == 1 && stats.mFirstMismatchPixel == 0 && stats.mFirstMismatchChannel == 1 && + stats.mFirstReference == 0.f && stats.mFirstCandidate == 2.f / 255.f && + stats.mFirstTolerance == TONEMAP_RGBA8_TOLERANCE); + ensure("comparison records the maximum absolute error", + stats.mMaximumAbsoluteError == static_cast(2.f / 255.f)); + + candidate.mCases[0].mPixels[0] = std::numeric_limits::quiet_NaN(); + stats = compareTonemapArtifacts(reference, candidate); + ensure("non-finite candidates fail preflight", !stats.mComparable && !stats.mMatch && !stats.mError.empty()); +} + +} diff --git a/indra/llrender/tests/llvulkanmaterialartifact_test.cpp b/indra/llrender/tests/llvulkanmaterialartifact_test.cpp new file mode 100644 index 00000000000..e8faf61a019 --- /dev/null +++ b/indra/llrender/tests/llvulkanmaterialartifact_test.cpp @@ -0,0 +1,465 @@ +/** + * @file llvulkanmaterialartifact_test.cpp + * @brief Tests for loading owned production Vulkan material shader artifacts. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llvulkanmaterialartifact.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; + +constexpr std::uint32_t SPIRV_MAGIC = 0x07230203U; +constexpr std::uint32_t SPIRV_VERSION_1_0 = 0x00010000U; +constexpr std::uint32_t OP_ENTRY_POINT = 15U; +constexpr std::uint32_t EXECUTION_MODEL_VERTEX = 0U; +constexpr std::uint32_t EXECUTION_MODEL_FRAGMENT = 4U; +constexpr std::uintmax_t MAX_MODULE_BYTES = 16U * 1024U * 1024U; + +const std::filesystem::path VERTEX_RELATIVE = std::filesystem::path("shaders") / "vulkan" / "legacy_normspec" / "production.vert.spv"; +const std::filesystem::path FRAGMENT_RELATIVE = std::filesystem::path("shaders") / "vulkan" / "legacy_normspec" / "production.frag.spv"; + +class TemporaryDirectory +{ +public: + TemporaryDirectory() + { + static std::atomic sequence{ 0 }; + std::error_code error; + const std::filesystem::path parent = std::filesystem::temp_directory_path(error); + if (error) + { + throw std::runtime_error("cannot locate the temporary directory"); + } + + const auto clock = static_cast(std::chrono::steady_clock::now().time_since_epoch().count()); + for (std::uint64_t attempt = 0; attempt < 128; ++attempt) + { + mPath = parent / ("llvulkanmaterialartifact-" + std::to_string(clock) + "-" + std::to_string(sequence.fetch_add(1))); + error.clear(); + if (std::filesystem::create_directory(mPath, error)) + { + return; + } + if (error && error != std::errc::file_exists) + { + throw std::runtime_error("cannot create a temporary directory"); + } + } + throw std::runtime_error("cannot reserve a unique temporary directory"); + } + + ~TemporaryDirectory() + { + std::error_code ignored; + std::filesystem::remove_all(mPath, ignored); + } + + TemporaryDirectory(const TemporaryDirectory&) = delete; + TemporaryDirectory& operator=(const TemporaryDirectory&) = delete; + + const std::filesystem::path& path() const noexcept { return mPath; } + +private: + std::filesystem::path mPath; +}; + +std::vector spirvString(const std::string& value, bool terminated) +{ + std::vector bytes(value.begin(), value.end()); + if (terminated) + { + bytes.push_back(0); + while (bytes.size() % sizeof(std::uint32_t) != 0) + { + bytes.push_back(0); + } + } + else if (bytes.size() % sizeof(std::uint32_t) != 0) + { + throw std::invalid_argument("unterminated fixture strings must fill whole words"); + } + + std::vector words(bytes.size() / sizeof(std::uint32_t), 0); + for (std::size_t index = 0; index < bytes.size(); ++index) + { + words[index / sizeof(std::uint32_t)] |= static_cast(bytes[index]) << (8U * (index % sizeof(std::uint32_t))); + } + return words; +} + +std::vector entryPoint(std::uint32_t execution_model, const std::string& name = "main", bool terminated = true) +{ + std::vector name_words = spirvString(name, terminated); + std::vector instruction{ static_cast((3U + name_words.size()) << 16U) | OP_ENTRY_POINT, execution_model, + 1U }; + instruction.insert(instruction.end(), name_words.begin(), name_words.end()); + return instruction; +} + +std::vector moduleWithInstructions(const std::vector>& instructions) +{ + std::vector words{ SPIRV_MAGIC, SPIRV_VERSION_1_0, 0U, 2U, 0U }; + for (const auto& instruction : instructions) + { + words.insert(words.end(), instruction.begin(), instruction.end()); + } + return words; +} + +std::vector validModule(ShaderStage stage) +{ + return moduleWithInstructions({ entryPoint(stage == ShaderStage::Vertex ? EXECUTION_MODEL_VERTEX : EXECUTION_MODEL_FRAGMENT) }); +} + +void writeBytes(const std::filesystem::path& path, const std::vector& bytes) +{ + std::filesystem::create_directories(path.parent_path()); + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output || + (!bytes.empty() && !output.write(reinterpret_cast(bytes.data()), static_cast(bytes.size())))) + { + throw std::runtime_error("cannot write SPIR-V test fixture"); + } +} + +void writeWords(const std::filesystem::path& path, const std::vector& words) +{ + std::vector bytes; + bytes.reserve(words.size() * sizeof(std::uint32_t)); + for (const std::uint32_t word : words) + { + bytes.push_back(static_cast(word)); + bytes.push_back(static_cast(word >> 8U)); + bytes.push_back(static_cast(word >> 16U)); + bytes.push_back(static_cast(word >> 24U)); + } + writeBytes(path, bytes); +} + +void writeValidPair(const std::filesystem::path& root) +{ + writeWords(root / VERTEX_RELATIVE, validModule(ShaderStage::Vertex)); + writeWords(root / FRAGMENT_RELATIVE, validModule(ShaderStage::Fragment)); +} + +void writeOversizedFile(const std::filesystem::path& path) +{ + std::filesystem::create_directories(path.parent_path()); + std::ofstream output(path, std::ios::binary | std::ios::trunc); + if (!output) + { + throw std::runtime_error("cannot open oversized SPIR-V test fixture"); + } + output.seekp(static_cast(MAX_MODULE_BYTES + 3U)); + output.put('\0'); + if (!output) + { + throw std::runtime_error("cannot write oversized SPIR-V test fixture"); + } +} + +bool hasError(const ShaderArtifactLoadResult& result, ShaderArtifactLoadCode code, std::optional stage) +{ + const auto* error = std::get_if(&result); + return error && error->mCode == code && error->mStage == stage; +} + +} // namespace + +namespace tut +{ + +struct vulkan_material_artifact_test +{ +}; + +using vulkan_material_artifact_test_group = test_group; +using vulkan_material_artifact_test_object = vulkan_material_artifact_test_group::object; +vulkan_material_artifact_test_group vulkan_material_artifact_tests("vulkan material artifact"); + +template<> +template<> +void vulkan_material_artifact_test_object::test<1>() +{ + const ShaderArtifactLoadResult default_result; + ensure("a default result fails closed", hasError(default_result, ShaderArtifactLoadCode::InvalidManifest, std::nullopt)); + + TemporaryDirectory temporary; + const std::filesystem::path root = temporary.path() / "app_settings"; + writeValidPair(root); + + const ShaderArtifactLoadResult result = loadLegacyNormSpecProductionArtifacts(root); + const auto* loaded = std::get_if(&result); + ensure("a valid production pair loads", loaded != nullptr); + + const auto modern = legacyNormSpecShaderManifest(legacyNormSpecModernHDRPipelineKey(), ShaderBackend::Vulkan); + const auto compatibility = legacyNormSpecShaderManifest(legacyNormSpecCompatibilityPipelineKey(), ShaderBackend::Vulkan); + ensure("both production profiles have Vulkan manifests", modern.has_value() && compatibility.has_value()); + ensure("both production manifests agree on the program", + modern->mProgram.mName == compatibility->mProgram.mName && modern->mProgram.mVariant == compatibility->mProgram.mVariant); + ensure("the loaded program comes from the canonical production manifest", + loaded->mProgram.mName == modern->mProgram.mName && loaded->mProgram.mVariant == modern->mProgram.mVariant); + ensure("the loaded stages and entry points are exact", + loaded->mVertex.mStage == ShaderStage::Vertex && loaded->mVertex.mEntryPoint == "main" && + loaded->mFragment.mStage == ShaderStage::Fragment && loaded->mFragment.mEntryPoint == "main"); + ensure("the loaded words match the little-endian fixtures", + loaded->mVertex.mWords == validModule(ShaderStage::Vertex) && loaded->mFragment.mWords == validModule(ShaderStage::Fragment)); +} + +template<> +template<> +void vulkan_material_artifact_test_object::test<2>() +{ + TemporaryDirectory temporary; + const std::filesystem::path missing_root = temporary.path() / "missing"; + ensure("a missing app-settings root fails without a partial program", + hasError(loadLegacyNormSpecProductionArtifacts(missing_root), ShaderArtifactLoadCode::MissingRoot, std::nullopt)); + + const std::filesystem::path root = temporary.path() / "app_settings"; + writeWords(root / FRAGMENT_RELATIVE, validModule(ShaderStage::Fragment)); + ensure("a missing vertex module is attributed to the vertex stage", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::MissingModule, ShaderStage::Vertex)); + + writeWords(root / VERTEX_RELATIVE, validModule(ShaderStage::Vertex)); + std::filesystem::remove(root / FRAGMENT_RELATIVE); + ensure("a missing fragment module discards the already loaded vertex module", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::MissingModule, ShaderStage::Fragment)); +} + +template<> +template<> +void vulkan_material_artifact_test_object::test<3>() +{ + TemporaryDirectory temporary; + const std::filesystem::path real_root = temporary.path() / "real_app_settings"; + const std::filesystem::path root_link = temporary.path() / "linked_app_settings"; + writeValidPair(real_root); + + std::error_code error; + std::filesystem::create_directory_symlink(real_root, root_link, error); + ensure("the root symlink fixture is created", !error); + ensure("an app-settings root symlink is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root_link), ShaderArtifactLoadCode::NotRegularFile, std::nullopt)); + + const std::filesystem::path root = temporary.path() / "app_settings"; + writeValidPair(root); + const std::filesystem::path external = temporary.path() / "external.vert.spv"; + writeWords(external, validModule(ShaderStage::Vertex)); + std::filesystem::remove(root / VERTEX_RELATIVE); + error.clear(); + std::filesystem::create_symlink(external, root / VERTEX_RELATIVE, error); + ensure("the module symlink fixture is created", !error); + ensure("a module symlink is rejected as nonregular", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::NotRegularFile, ShaderStage::Vertex)); + + std::filesystem::remove(root / VERTEX_RELATIVE); + std::filesystem::create_directory(root / VERTEX_RELATIVE); + ensure("a directory in place of a module is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::NotRegularFile, ShaderStage::Vertex)); + + std::filesystem::remove_all(root / VERTEX_RELATIVE); + writeWords(root / VERTEX_RELATIVE, validModule(ShaderStage::Vertex)); + const std::filesystem::path external_fragment = temporary.path() / "external.frag.spv"; + writeWords(external_fragment, validModule(ShaderStage::Fragment)); + std::filesystem::remove(root / FRAGMENT_RELATIVE); + error.clear(); + std::filesystem::create_symlink(external_fragment, root / FRAGMENT_RELATIVE, error); + ensure("the fragment symlink fixture is created", !error); + ensure("a fragment-module symlink is attributed to the fragment stage", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::NotRegularFile, ShaderStage::Fragment)); + + std::filesystem::remove_all(root); + writeValidPair(root); + const std::filesystem::path real_vulkan = temporary.path() / "real_vulkan"; + std::filesystem::rename(root / "shaders" / "vulkan", real_vulkan); + error.clear(); + std::filesystem::create_directory_symlink(real_vulkan, root / "shaders" / "vulkan", error); + ensure("the intermediate-directory symlink fixture is created", !error); + ensure("an intermediate directory symlink is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::NotRegularFile, ShaderStage::Vertex)); +} + +template<> +template<> +void vulkan_material_artifact_test_object::test<4>() +{ + TemporaryDirectory temporary; + const std::filesystem::path root = temporary.path() / "app_settings"; + writeValidPair(root); + + writeBytes(root / VERTEX_RELATIVE, {}); + ensure("an empty module is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::InvalidSize, ShaderStage::Vertex)); + + writeWords(root / VERTEX_RELATIVE, { SPIRV_MAGIC, SPIRV_VERSION_1_0, 0U, 2U }); + ensure("a module shorter than the five-word header is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::InvalidSize, ShaderStage::Vertex)); + + writeWords(root / VERTEX_RELATIVE, validModule(ShaderStage::Vertex)); + std::ofstream append(root / VERTEX_RELATIVE, std::ios::binary | std::ios::app); + append.put('\0'); + append.close(); + ensure("a module whose byte size is not word-aligned is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::InvalidSize, ShaderStage::Vertex)); +} + +template<> +template<> +void vulkan_material_artifact_test_object::test<5>() +{ + TemporaryDirectory temporary; + const std::filesystem::path root = temporary.path() / "app_settings"; + writeValidPair(root); + writeOversizedFile(root / VERTEX_RELATIVE); + ensure("a word-aligned module larger than 16 MiB is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::InvalidSize, ShaderStage::Vertex)); +} + +template<> +template<> +void vulkan_material_artifact_test_object::test<6>() +{ + TemporaryDirectory temporary; + const std::filesystem::path root = temporary.path() / "app_settings"; + writeValidPair(root); + std::vector wrong_magic = validModule(ShaderStage::Fragment); + wrong_magic.front() = 0U; + writeWords(root / FRAGMENT_RELATIVE, wrong_magic); + ensure("wrong SPIR-V magic fails without returning the valid vertex module", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::InvalidSpirv, ShaderStage::Fragment)); +} + +template<> +template<> +void vulkan_material_artifact_test_object::test<7>() +{ + TemporaryDirectory temporary; + const std::filesystem::path root = temporary.path() / "app_settings"; + writeValidPair(root); + + writeWords(root / VERTEX_RELATIVE, moduleWithInstructions({ { OP_ENTRY_POINT, EXECUTION_MODEL_VERTEX, 1U, 0x6e69616dU, 0U } })); + ensure("an instruction with a zero word count is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::InvalidSpirv, ShaderStage::Vertex)); + + std::vector overrun = entryPoint(EXECUTION_MODEL_VERTEX); + overrun.front() += 1U << 16U; + writeWords(root / VERTEX_RELATIVE, moduleWithInstructions({ overrun })); + ensure("an instruction extending past the module is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::InvalidSpirv, ShaderStage::Vertex)); +} + +template<> +template<> +void vulkan_material_artifact_test_object::test<8>() +{ + TemporaryDirectory temporary; + const std::filesystem::path root = temporary.path() / "app_settings"; + writeValidPair(root); + + writeWords(root / VERTEX_RELATIVE, moduleWithInstructions({ { 1U << 16U } })); + ensure("a module without an entry point is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::WrongEntryPoint, ShaderStage::Vertex)); + + writeWords(root / VERTEX_RELATIVE, moduleWithInstructions({ entryPoint(EXECUTION_MODEL_VERTEX, "else") })); + ensure("a renamed entry point is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::WrongEntryPoint, ShaderStage::Vertex)); + + writeWords(root / VERTEX_RELATIVE, moduleWithInstructions({ entryPoint(EXECUTION_MODEL_VERTEX, "main", false) })); + ensure("an unterminated entry-point name is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::WrongEntryPoint, ShaderStage::Vertex)); + + auto nonzero_padding = entryPoint(EXECUTION_MODEL_VERTEX); + nonzero_padding.back() |= 0x00000100U; + writeWords(root / VERTEX_RELATIVE, moduleWithInstructions({ nonzero_padding })); + ensure("nonzero bytes after the entry-point terminator are rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::WrongEntryPoint, ShaderStage::Vertex)); + + writeWords(root / VERTEX_RELATIVE, moduleWithInstructions({ { (3U << 16U) | OP_ENTRY_POINT, EXECUTION_MODEL_VERTEX, 1U } })); + ensure("an entry-point instruction without a name is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::WrongEntryPoint, ShaderStage::Vertex)); +} + +template<> +template<> +void vulkan_material_artifact_test_object::test<9>() +{ + TemporaryDirectory temporary; + const std::filesystem::path root = temporary.path() / "app_settings"; + writeValidPair(root); + + const auto vertex_entry = entryPoint(EXECUTION_MODEL_VERTEX); + writeWords(root / VERTEX_RELATIVE, moduleWithInstructions({ vertex_entry, vertex_entry })); + ensure("duplicate main entry points are rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::WrongEntryPoint, ShaderStage::Vertex)); + + writeWords(root / VERTEX_RELATIVE, moduleWithInstructions({ vertex_entry, entryPoint(EXECUTION_MODEL_VERTEX, "else") })); + ensure("an extra named entry point is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::WrongEntryPoint, ShaderStage::Vertex)); +} + +template<> +template<> +void vulkan_material_artifact_test_object::test<10>() +{ + TemporaryDirectory temporary; + const std::filesystem::path root = temporary.path() / "app_settings"; + writeWords(root / VERTEX_RELATIVE, validModule(ShaderStage::Fragment)); + writeWords(root / FRAGMENT_RELATIVE, validModule(ShaderStage::Vertex)); + ensure("swapped execution models are rejected at the first module", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::WrongStage, ShaderStage::Vertex)); + + writeWords(root / VERTEX_RELATIVE, validModule(ShaderStage::Vertex)); + ensure("a fragment module carrying the vertex execution model is rejected", + hasError(loadLegacyNormSpecProductionArtifacts(root), ShaderArtifactLoadCode::WrongStage, ShaderStage::Fragment)); +} + +template<> +template<> +void vulkan_material_artifact_test_object::test<11>() +{ + TemporaryDirectory temporary; + const std::filesystem::path root = temporary.path() / "app_settings"; + writeValidPair(root); + + ShaderArtifactLoadResult result = loadLegacyNormSpecProductionArtifacts(root); + const auto* loaded = std::get_if(&result); + ensure("the ownership fixture loads", loaded != nullptr); + const std::vector vertex_words = loaded->mVertex.mWords; + const std::vector fragment_words = loaded->mFragment.mWords; + + writeWords(root / VERTEX_RELATIVE, { 0U, 0U, 0U, 0U, 0U }); + writeWords(root / FRAGMENT_RELATIVE, { 0U, 0U, 0U, 0U, 0U }); + std::error_code error; + std::filesystem::remove_all(root, error); + ensure("the loaded program remains owned after source mutation and deletion", + !error && loaded->mVertex.mWords == vertex_words && loaded->mFragment.mWords == fragment_words && + loaded->mVertex.mStage == ShaderStage::Vertex && loaded->mFragment.mStage == ShaderStage::Fragment); +} + +} // namespace tut diff --git a/indra/llrender/tests/llvulkanmaterialpublication_test.cpp b/indra/llrender/tests/llvulkanmaterialpublication_test.cpp new file mode 100644 index 00000000000..c0915e37f15 --- /dev/null +++ b/indra/llrender/tests/llvulkanmaterialpublication_test.cpp @@ -0,0 +1,312 @@ +/** + * @file llvulkanmaterialpublication_test.cpp + * @brief Tests for neutral material shader publication and retirement. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llvulkanmaterialpublication.h" +#include "lltut.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; + +constexpr std::uint32_t SPIRV_MAGIC = 0x07230203U; +constexpr std::uint32_t SPIRV_VERSION_1_0 = 0x00010000U; +constexpr std::uint32_t OP_ENTRY_POINT = 15U; +constexpr std::uint32_t EXECUTION_MODEL_VERTEX = 0U; +constexpr std::uint32_t EXECUTION_MODEL_FRAGMENT = 4U; +constexpr std::size_t MAX_MODULE_WORDS = (16U * 1024U * 1024U) / sizeof(std::uint32_t); + +ShaderProgramKey canonicalKey() +{ + return legacyNormSpecModernHDRPipelineKey().mProgram; +} + +std::vector module(ShaderStage stage, std::uint32_t generator) +{ + const std::uint32_t execution_model = stage == ShaderStage::Vertex ? EXECUTION_MODEL_VERTEX : EXECUTION_MODEL_FRAGMENT; + return { SPIRV_MAGIC, SPIRV_VERSION_1_0, generator, 2U, 0U, (5U << 16U) | OP_ENTRY_POINT, execution_model, 1U, 0x6e69616dU, 0U }; +} + +// This fixture passes the Stage 20 bounded runtime checks. It is deliberately +// not evidence of spirv-val, reflection, or packaged-byte provenance. +LoadedShaderProgram structurallyAcceptedProgram(std::uint32_t generator) +{ + return { canonicalKey(), + { ShaderStage::Vertex, "main", module(ShaderStage::Vertex, generator) }, + { ShaderStage::Fragment, "main", module(ShaderStage::Fragment, generator) } }; +} + +} // namespace + +namespace tut +{ + +struct vulkan_material_publication_test +{ +}; + +using vulkan_material_publication_test_group = test_group; +using vulkan_material_publication_test_object = vulkan_material_publication_test_group::object; +vulkan_material_publication_test_group vulkan_material_publication_tests("vulkan material publication"); + +template<> +template<> +void vulkan_material_publication_test_object::test<1>() +{ + static_assert(!std::is_same_v); + static_assert(!std::is_same_v); + static_assert(!std::is_copy_constructible_v); + static_assert(!std::is_copy_assignable_v); + static_assert(!std::is_move_constructible_v); + static_assert(!std::is_move_assignable_v); + + const auto next = nextHandleGeneration(ShaderHandle{ 7, 9 }); + ensure("the next generation preserves its typed index", next && *next == ShaderHandle{ 7, 10 }); + const auto last = nextHandleGeneration(ShaderHandle{ 7, std::numeric_limits::max() - 1 }); + ensure("the maximum generation itself remains reachable", + last && *last == ShaderHandle{ 7, std::numeric_limits::max() }); + ensure("a zero handle has no next generation", !nextHandleGeneration(ShaderHandle{})); + ensure("a zero index has no next generation", !nextHandleGeneration(ShaderHandle{ 0, 9 })); + ensure("a zero generation has no next generation", !nextHandleGeneration(ShaderHandle{ 7, 0 })); + ensure("generation wrap fails closed", !nextHandleGeneration(ShaderHandle{ 7, std::numeric_limits::max() })); +} + +template<> +template<> +void vulkan_material_publication_test_object::test<2>() +{ + LegacyNormSpecShaderPublication publication; + ensure("an empty owner has no canonical current handle", !publication.current(canonicalKey())); + + LoadedShaderProgram original = structurallyAcceptedProgram(17); + std::uint32_t* aliased_word = &original.mVertex.mWords[2]; + const auto handle = publication.publish(std::move(original)); + ensure("the first canonical publication uses the fixed initial handle", handle && *handle == ShaderHandle{ 1, 1 }); + ensure("the canonical program resolves to the current handle", publication.current(canonicalKey()) == handle); + + ShaderProgramKey wrong_key = canonicalKey(); + ++wrong_key.mVariant; + ensure("a wrong program key has no current handle", !publication.current(wrong_key)); + + const auto lease = publication.resolveForFrame(*handle, 3); + ensure("the exact current handle acquires an immutable frame lease", + lease && lease->mHandle == *handle && lease->mFrame == 3 && lease->mProgram); + *aliased_word = 99; + ensure("published words do not retain aliases from an rvalue caller", lease->mProgram->mVertex.mWords[2] == 17); +} + +template<> +template<> +void vulkan_material_publication_test_object::test<3>() +{ + LoadedShaderProgram value = structurallyAcceptedProgram(1); + ensure("the canonical in-memory program passes bounded runtime checks", validLegacyNormSpecProductionShaderProgram(value)); + + value.mProgram.mName = "other.material"; + ensure("a wrong program identity is invalid", !validLegacyNormSpecProductionShaderProgram(value)); + value = structurallyAcceptedProgram(1); + ++value.mProgram.mVariant; + ensure("a wrong program variant is invalid", !validLegacyNormSpecProductionShaderProgram(value)); + value = structurallyAcceptedProgram(1); + value.mVertex.mStage = ShaderStage::Fragment; + ensure("a mislabeled vertex stage is invalid", !validLegacyNormSpecProductionShaderProgram(value)); + value = structurallyAcceptedProgram(1); + value.mVertex.mEntryPoint = "other"; + ensure("a wrong vertex entry-point label is invalid", !validLegacyNormSpecProductionShaderProgram(value)); + value = structurallyAcceptedProgram(1); + value.mFragment.mStage = ShaderStage::Vertex; + ensure("a mislabeled fragment stage is invalid", !validLegacyNormSpecProductionShaderProgram(value)); + value = structurallyAcceptedProgram(1); + value.mFragment.mEntryPoint = "other"; + ensure("a wrong fragment entry-point label is invalid", !validLegacyNormSpecProductionShaderProgram(value)); + value = structurallyAcceptedProgram(1); + value.mVertex.mWords[0] = 0; + ensure("invalid vertex words are rejected", !validLegacyNormSpecProductionShaderProgram(value)); + value = structurallyAcceptedProgram(1); + value.mFragment.mWords[6] = EXECUTION_MODEL_VERTEX; + ensure("invalid fragment execution-model words are rejected", !validLegacyNormSpecProductionShaderProgram(value)); + value = structurallyAcceptedProgram(1); + value.mVertex.mWords.resize(MAX_MODULE_WORDS + 1, 0U); + ensure("an in-memory module cannot bypass the file-size ceiling", !validLegacyNormSpecProductionShaderProgram(value)); + + LegacyNormSpecShaderPublication publication; + ensure("an invalid first publication fails atomically", + !publication.publish(value) && !publication.current(canonicalKey()) && publication.completedThrough() == 0); +} + +template<> +template<> +void vulkan_material_publication_test_object::test<4>() +{ + LegacyNormSpecShaderPublication publication; + const auto published = publication.publish(structurallyAcceptedProgram(1)); + ensure("the rejection fixture publishes", published.has_value()); + const ShaderHandle handle = *published; + ensure("the setup frame resolves", publication.resolveForFrame(handle, 10).has_value()); + + ensure("a zero handle is rejected", !publication.resolveForFrame({}, 10)); + ensure("an unknown index is rejected", !publication.resolveForFrame({ 9, 1 }, 100)); + ensure("an old generation is rejected", !publication.resolveForFrame({ 1, 0 }, 100)); + ensure("a future generation is rejected", !publication.resolveForFrame({ 1, 2 }, 100)); + ensure("a zero frame is rejected", !publication.resolveForFrame(handle, 0)); + ensure("rejections do not advance record order", publication.resolveForFrame(handle, 10).has_value()); + ensure("recorded frame regression is rejected", !publication.resolveForFrame(handle, 9)); + + const auto completed = publication.completeThrough(10); + ensure("completion does not retire the current generation", completed && completed->empty()); + ensure("a completed frame cannot acquire a lease", !publication.resolveForFrame(handle, 10)); + ensure("the next frame can still acquire the current generation", publication.resolveForFrame(handle, 11).has_value()); +} + +template<> +template<> +void vulkan_material_publication_test_object::test<5>() +{ + LegacyNormSpecShaderPublication publication; + LoadedShaderProgram first = structurallyAcceptedProgram(1); + const auto old_published = publication.publish(first); + ensure("the replacement fixture first generation publishes", old_published.has_value()); + const ShaderHandle old_handle = *old_published; + auto old_lease = publication.resolveForFrame(old_handle, 7); + ensure("the old generation lease is acquired", old_lease.has_value()); + std::weak_ptr old_storage = old_lease->mProgram; + + LoadedShaderProgram replacement = structurallyAcceptedProgram(2); + const auto new_handle = publication.publish(replacement); + ensure("replacement preserves the index and advances exactly one generation", new_handle && *new_handle == ShaderHandle{ 1, 2 }); + ensure("record order cannot regress across a replacement", !publication.resolveForFrame(*new_handle, 6)); + ensure("old and replacement leases may coexist in the same recorded frame", publication.resolveForFrame(*new_handle, 7).has_value()); + ensure("the replaced handle is stale immediately", !publication.resolveForFrame(old_handle, 8)); + ensure("the replacement is the only canonical current generation", publication.current(canonicalKey()) == new_handle); + + auto new_lease = publication.resolveForFrame(*new_handle, 8); + replacement.mFragment.mWords[2] = 99; + ensure("replacement bytes are immutable after caller mutation", new_lease && new_lease->mProgram->mFragment.mWords[2] == 2); + old_lease.reset(); + ensure("the owner retains superseded bytes before completion", !old_storage.expired()); +} + +template<> +template<> +void vulkan_material_publication_test_object::test<6>() +{ + LegacyNormSpecShaderPublication publication; + const auto old_published = publication.publish(structurallyAcceptedProgram(1)); + ensure("the completion fixture first generation publishes", old_published.has_value()); + const ShaderHandle old_handle = *old_published; + auto old_lease = publication.resolveForFrame(old_handle, 10); + ensure("the last-use lease is acquired", old_lease.has_value()); + std::weak_ptr old_storage = old_lease->mProgram; + const auto new_published = publication.publish(structurallyAcceptedProgram(2)); + ensure("the completion fixture replacement publishes", new_published.has_value()); + const ShaderHandle new_handle = *new_published; + old_lease.reset(); + + const auto early = publication.completeThrough(9); + ensure("completion before last use retires nothing", early && early->empty() && !old_storage.expired()); + const auto exact = publication.completeThrough(10); + ensure("completion at last use retires the exact superseded generation", + exact && *exact == std::vector{ { old_handle, 10 } } && old_storage.expired()); + ensure("the current generation survives completion", + publication.current(canonicalKey()) == new_handle && publication.resolveForFrame(new_handle, 11).has_value()); +} + +template<> +template<> +void vulkan_material_publication_test_object::test<7>() +{ + LegacyNormSpecShaderPublication publication; + const auto first_published = publication.publish(structurallyAcceptedProgram(1)); + ensure("the ordered-retirement fixture first generation publishes", first_published.has_value()); + const ShaderHandle first = *first_published; + auto first_lease = publication.resolveForFrame(first, 5); + const auto second_published = publication.publish(structurallyAcceptedProgram(2)); + ensure("the ordered-retirement fixture second generation publishes", second_published.has_value()); + const ShaderHandle second = *second_published; + auto second_lease = publication.resolveForFrame(second, 8); + const auto third_published = publication.publish(structurallyAcceptedProgram(3)); + ensure("the ordered-retirement fixture third generation publishes", third_published.has_value()); + const ShaderHandle third = *third_published; + first_lease.reset(); + second_lease.reset(); + + const auto retired = publication.completeThrough(8); + ensure("multiple generations retire in publication order", + retired && *retired == std::vector{ { first, 5 }, { second, 8 } }); + ensure("the newest generation is never retired by completion alone", + publication.current(canonicalKey()) == third && publication.resolveForFrame(third, 9).has_value()); +} + +template<> +template<> +void vulkan_material_publication_test_object::test<8>() +{ + LegacyNormSpecShaderPublication publication; + ensure("zero completion fails without advancing the watermark", !publication.completeThrough(0) && publication.completedThrough() == 0); + const auto initial_completion = publication.completeThrough(5); + ensure("completion may precede first publication", initial_completion && initial_completion->empty()); + const auto first_published = publication.publish(structurallyAcceptedProgram(1)); + ensure("the atomicity fixture first generation publishes", first_published.has_value()); + const ShaderHandle first = *first_published; + + LoadedShaderProgram invalid = structurallyAcceptedProgram(2); + invalid.mFragment.mEntryPoint = "wrong"; + ensure("an invalid replacement leaves the current generation unchanged", + !publication.publish(invalid) && publication.current(canonicalKey()) == first); + + const auto second_published = publication.publish(structurallyAcceptedProgram(2)); + ensure("the publication after an invalid replacement is exactly the next generation", + second_published && *second_published == ShaderHandle{ 1, 2 }); + const ShaderHandle second = *second_published; + const auto equal_completion = publication.completeThrough(5); + ensure("an equal watermark retires a newly pending generation with no recorded use", + equal_completion && *equal_completion == std::vector{ { first, 0 } }); + ensure("completion regression fails without changing the watermark or current generation", + !publication.completeThrough(4) && publication.completedThrough() == 5 && publication.current(canonicalKey()) == second); + const auto repeated = publication.completeThrough(5); + ensure("equal completion is otherwise idempotent", repeated && repeated->empty()); +} + +template<> +template<> +void vulkan_material_publication_test_object::test<9>() +{ + LegacyNormSpecShaderPublication publication; + const auto first_published = publication.publish(structurallyAcceptedProgram(1)); + ensure("the external-lease fixture first generation publishes", first_published.has_value()); + const ShaderHandle first = *first_published; + auto lease = publication.resolveForFrame(first, 3); + ensure("the external ownership lease is acquired", lease.has_value()); + std::weak_ptr storage = lease->mProgram; + ensure("the external-lease fixture replacement publishes", publication.publish(structurallyAcceptedProgram(2)).has_value()); + + const auto retired = publication.completeThrough(3); + ensure("logical retirement is reported while an outside lease remains valid", + retired && *retired == std::vector{ { first, 3 } } && !storage.expired() && + lease->mProgram->mVertex.mWords[2] == 1); + ensure("the retired handle cannot be reacquired", !publication.resolveForFrame(first, 4)); + lease.reset(); + ensure("physical bytes release after the final outside owner", storage.expired()); +} + +} // namespace tut diff --git a/indra/llrender/vulkan/llmaterialcompare_main.cpp b/indra/llrender/vulkan/llmaterialcompare_main.cpp new file mode 100644 index 00000000000..9093de33769 --- /dev/null +++ b/indra/llrender/vulkan/llmaterialcompare_main.cpp @@ -0,0 +1,85 @@ +/** + * @file llmaterialcompare_main.cpp + * @brief Strict comparison for fixed-input indexed-material artifacts. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llmaterialdiagnostic.h" + +#include +#include +#include +#include + +namespace +{ + +int fail(const std::string& reason, const std::string& detail) +{ + std::cerr << "MATERIAL_COMPARE result=fail reason=" << reason; + if (!detail.empty()) + { + std::cerr << " detail={" << detail << '}'; + } + std::cerr << '\n'; + return 1; +} + +} // namespace + +int main(int argc, char** argv) +{ + if (argc != 3) + { + std::cerr << "usage: llmaterialcompare \n"; + return 2; + } + + LLRenderContract::MaterialArtifact reference; + LLRenderContract::MaterialArtifact candidate; + std::string error; + if (!LLRenderContract::readMaterialArtifact(std::filesystem::path(argv[1]), reference, &error)) + { + return fail("reference_read", error); + } + if (!LLRenderContract::readMaterialArtifact(std::filesystem::path(argv[2]), candidate, &error)) + { + return fail("candidate_read", error); + } + + const LLRenderContract::MaterialComparisonStats stats = + LLRenderContract::compareMaterialArtifacts(reference, candidate); + if (!stats.mComparable) + { + return fail("not_comparable", stats.mError); + } + + std::cout << std::setprecision(9) + << "MATERIAL_COMPARE result=" << (stats.mMatch ? "pass" : "fail") + << " components=" << stats.mComparedComponents + << " mismatches=" << stats.mMismatchCount + << " max_abs_error=" << stats.mMaximumAbsoluteError + << " rgba8_tolerance=" << LLRenderContract::MATERIAL_RGBA8_TOLERANCE + << " rgba16_tolerance=" << LLRenderContract::MATERIAL_RGBA16_TOLERANCE + << " depth24_tolerance=" << LLRenderContract::MATERIAL_DEPTH24_TOLERANCE; + if (!stats.mMatch) + { + std::cout << " first_plane=" << stats.mFirstMismatchPlane + << " first_pixel=" << stats.mFirstMismatchPixel + << " first_channel=" << stats.mFirstMismatchChannel + << " reference=" << stats.mFirstReference + << " candidate=" << stats.mFirstCandidate + << " tolerance=" << stats.mFirstTolerance; + } + std::cout << '\n'; + return stats.mMatch ? 0 : 1; +} diff --git a/indra/llrender/vulkan/llrendervulkanglobaldispatch.cpp b/indra/llrender/vulkan/llrendervulkanglobaldispatch.cpp new file mode 100644 index 00000000000..66b461252d6 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanglobaldispatch.cpp @@ -0,0 +1,113 @@ +/** + * @file llrendervulkanglobaldispatch.cpp + * @brief Loader-independent validation of Vulkan global command dispatch. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkanglobaldispatch.h" + +namespace LLRenderVulkan +{ + +struct VulkanGlobalDispatchGenerationFactory +{ + static VulkanGlobalDispatchGeneration create(PFN_vkGetInstanceProcAddr get_instance_proc_addr, + PFN_vkCreateInstance create_instance, + PFN_vkEnumerateInstanceExtensionProperties enumerate_instance_extension_properties, + PFN_vkEnumerateInstanceLayerProperties enumerate_instance_layer_properties, + PFN_vkEnumerateInstanceVersion enumerate_instance_version, + std::uint32_t loader_api_version) noexcept + { + return VulkanGlobalDispatchGeneration(get_instance_proc_addr, create_instance, enumerate_instance_extension_properties, + enumerate_instance_layer_properties, enumerate_instance_version, loader_api_version); + } +}; + +namespace +{ + + VulkanGlobalDispatchResolutionError failure(VulkanGlobalDispatchResolutionCode code, + std::optional command = std::nullopt, + VkResult result = VK_SUCCESS, + std::uint32_t available_api_version = 0) noexcept + { + return { code, command, result, available_api_version }; + } + + template + Function resolve(PFN_vkGetInstanceProcAddr get_instance_proc_addr, const char* name) noexcept + { + return reinterpret_cast(get_instance_proc_addr(VK_NULL_HANDLE, name)); + } + +} // namespace + +VulkanGlobalDispatchResolutionResult resolveVulkanGlobalDispatchGeneration(PFN_vkGetInstanceProcAddr get_instance_proc_addr) noexcept +{ + if (!get_instance_proc_addr) + { + return failure(VulkanGlobalDispatchResolutionCode::InvalidGetInstanceProcAddr); + } + + const PFN_vkCreateInstance create_instance = resolve(get_instance_proc_addr, "vkCreateInstance"); + if (!create_instance) + { + return failure(VulkanGlobalDispatchResolutionCode::MissingRequiredCommand, VulkanGlobalCommand::CreateInstance); + } + + const PFN_vkEnumerateInstanceExtensionProperties enumerate_instance_extension_properties = + resolve(get_instance_proc_addr, "vkEnumerateInstanceExtensionProperties"); + if (!enumerate_instance_extension_properties) + { + return failure(VulkanGlobalDispatchResolutionCode::MissingRequiredCommand, + VulkanGlobalCommand::EnumerateInstanceExtensionProperties); + } + + const PFN_vkEnumerateInstanceLayerProperties enumerate_instance_layer_properties = + resolve(get_instance_proc_addr, "vkEnumerateInstanceLayerProperties"); + if (!enumerate_instance_layer_properties) + { + return failure(VulkanGlobalDispatchResolutionCode::MissingRequiredCommand, VulkanGlobalCommand::EnumerateInstanceLayerProperties); + } + + const PFN_vkEnumerateInstanceVersion enumerate_instance_version = + resolve(get_instance_proc_addr, "vkEnumerateInstanceVersion"); + if (!enumerate_instance_version) + { + return failure(VulkanGlobalDispatchResolutionCode::InsufficientApiVersion, VulkanGlobalCommand::EnumerateInstanceVersion, + VK_SUCCESS, VK_API_VERSION_1_0); + } + + std::uint32_t loader_api_version = 0; + const VkResult version_result = enumerate_instance_version(&loader_api_version); + if (version_result != VK_SUCCESS) + { + return failure(VulkanGlobalDispatchResolutionCode::VersionQueryFailure, VulkanGlobalCommand::EnumerateInstanceVersion, + version_result); + } + if (VK_API_VERSION_VARIANT(loader_api_version) != 0) + { + return failure(VulkanGlobalDispatchResolutionCode::UnsupportedApiVariant, VulkanGlobalCommand::EnumerateInstanceVersion, VK_SUCCESS, + loader_api_version); + } + if (loader_api_version < RENDERER_VULKAN_API_VERSION) + { + return failure(VulkanGlobalDispatchResolutionCode::InsufficientApiVersion, VulkanGlobalCommand::EnumerateInstanceVersion, + VK_SUCCESS, loader_api_version); + } + + return VulkanGlobalDispatchGenerationFactory::create(get_instance_proc_addr, create_instance, enumerate_instance_extension_properties, + enumerate_instance_layer_properties, enumerate_instance_version, + loader_api_version); +} + +} // namespace LLRenderVulkan diff --git a/indra/llrender/vulkan/llrendervulkanglobaldispatch.h b/indra/llrender/vulkan/llrendervulkanglobaldispatch.h new file mode 100644 index 00000000000..7c0d30b14c6 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanglobaldispatch.h @@ -0,0 +1,111 @@ +/** + * @file llrendervulkanglobaldispatch.h + * @brief Loader-independent validation of Vulkan global command dispatch. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANGLOBALDISPATCH_H +#define LL_LLRENDERVULKANGLOBALDISPATCH_H + +#include + +#include +#include +#include + +namespace LLRenderVulkan +{ + +inline constexpr std::uint32_t RENDERER_VULKAN_API_VERSION = VK_API_VERSION_1_1; + +enum class VulkanGlobalCommand : std::uint8_t +{ + CreateInstance, + EnumerateInstanceExtensionProperties, + EnumerateInstanceLayerProperties, + EnumerateInstanceVersion +}; + +enum class VulkanGlobalDispatchResolutionCode : std::uint8_t +{ + InvalidGetInstanceProcAddr, + MissingRequiredCommand, + VersionQueryFailure, + UnsupportedApiVariant, + InsufficientApiVersion +}; + +struct VulkanGlobalDispatchResolutionError +{ + VulkanGlobalDispatchResolutionCode mCode = VulkanGlobalDispatchResolutionCode::InvalidGetInstanceProcAddr; + std::optional mCommand; + VkResult mResult = VK_SUCCESS; + std::uint32_t mAvailableApiVersion = 0; + + friend constexpr bool operator==(const VulkanGlobalDispatchResolutionError&, const VulkanGlobalDispatchResolutionError&) = default; +}; + +// The resolver and its loader implementation remain owned by the caller and +// must outlive every copy of this immutable generation. This value owns no +// dynamic library, Vulkan object, extension data, or unload policy. +class VulkanGlobalDispatchGeneration +{ +public: + VulkanGlobalDispatchGeneration(const VulkanGlobalDispatchGeneration&) noexcept = default; + VulkanGlobalDispatchGeneration(VulkanGlobalDispatchGeneration&&) noexcept = default; + + VulkanGlobalDispatchGeneration& operator=(const VulkanGlobalDispatchGeneration&) = delete; + VulkanGlobalDispatchGeneration& operator=(VulkanGlobalDispatchGeneration&&) = delete; + + PFN_vkGetInstanceProcAddr getInstanceProcAddr() const noexcept { return mGetInstanceProcAddr; } + PFN_vkCreateInstance createInstance() const noexcept { return mCreateInstance; } + PFN_vkEnumerateInstanceExtensionProperties enumerateInstanceExtensionProperties() const noexcept + { + return mEnumerateInstanceExtensionProperties; + } + PFN_vkEnumerateInstanceLayerProperties enumerateInstanceLayerProperties() const noexcept { return mEnumerateInstanceLayerProperties; } + PFN_vkEnumerateInstanceVersion enumerateInstanceVersion() const noexcept { return mEnumerateInstanceVersion; } + std::uint32_t loaderApiVersion() const noexcept { return mLoaderApiVersion; } + +private: + friend struct VulkanGlobalDispatchGenerationFactory; + + constexpr VulkanGlobalDispatchGeneration(PFN_vkGetInstanceProcAddr get_instance_proc_addr, + PFN_vkCreateInstance create_instance, + PFN_vkEnumerateInstanceExtensionProperties enumerate_instance_extension_properties, + PFN_vkEnumerateInstanceLayerProperties enumerate_instance_layer_properties, + PFN_vkEnumerateInstanceVersion enumerate_instance_version, + std::uint32_t loader_api_version) noexcept : + mGetInstanceProcAddr(get_instance_proc_addr), + mCreateInstance(create_instance), + mEnumerateInstanceExtensionProperties(enumerate_instance_extension_properties), + mEnumerateInstanceLayerProperties(enumerate_instance_layer_properties), + mEnumerateInstanceVersion(enumerate_instance_version), + mLoaderApiVersion(loader_api_version) + { + } + + const PFN_vkGetInstanceProcAddr mGetInstanceProcAddr; + const PFN_vkCreateInstance mCreateInstance; + const PFN_vkEnumerateInstanceExtensionProperties mEnumerateInstanceExtensionProperties; + const PFN_vkEnumerateInstanceLayerProperties mEnumerateInstanceLayerProperties; + const PFN_vkEnumerateInstanceVersion mEnumerateInstanceVersion; + const std::uint32_t mLoaderApiVersion; +}; + +using VulkanGlobalDispatchResolutionResult = std::variant; + +VulkanGlobalDispatchResolutionResult resolveVulkanGlobalDispatchGeneration(PFN_vkGetInstanceProcAddr get_instance_proc_addr) noexcept; + +} // namespace LLRenderVulkan + +#endif // LL_LLRENDERVULKANGLOBALDISPATCH_H diff --git a/indra/llrender/vulkan/llrendervulkanmaterial.cpp b/indra/llrender/vulkan/llrendervulkanmaterial.cpp new file mode 100644 index 00000000000..b874b14cae8 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterial.cpp @@ -0,0 +1,677 @@ +/** + * @file llrendervulkanmaterial.cpp + * @brief Vulkan replay of the canonical Stage 12 material packet. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkanmaterial.h" + +#include +#include +#include +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ + +bool validMaterialDescriptorSetPair(const std::array& descriptor_sets) noexcept +{ + return descriptor_sets[0] != VK_NULL_HANDLE && descriptor_sets[1] != VK_NULL_HANDLE; +} + +namespace +{ + +constexpr VkImageUsageFlags MATERIAL_SOURCE_USAGE = + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; +constexpr VkImageUsageFlags MATERIAL_COLOR_USAGE = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT | + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; +constexpr VkImageUsageFlags MATERIAL_DEPTH_USAGE = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT; +constexpr VkColorComponentFlags MATERIAL_COLOR_WRITE_MASK = VK_COLOR_COMPONENT_R_BIT | + VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | + VK_COLOR_COMPONENT_A_BIT; + +template +bool hasIndex(const std::vector& entries, Handle handle) +{ + return std::any_of(entries.begin(), entries.end(), + [handle](const Entry& entry) { return entry.mHandle.mIndex == handle.mIndex; }); +} + +template +bool addEntry(std::vector& entries, Handle handle, Binding binding, bool complete) +{ + if (!handle || !complete || hasIndex(entries, handle)) + { + return false; + } + entries.push_back({ handle, std::move(binding) }); + return true; +} + +template +const auto* resolveEntry(const std::vector& entries, Handle handle) +{ + const auto found = std::find_if(entries.begin(), entries.end(), + [handle](const Entry& entry) { return entry.mHandle == handle; }); + return found == entries.end() ? nullptr : &found->mBinding; +} + +bool nonzeroIdentity(const ShaderIdentityToken& identity) +{ + return std::any_of(identity.begin(), identity.end(), [](std::uint8_t byte) { return byte != 0; }); +} + +bool sameExtent(LLRenderContract::Extent2D left, LLRenderContract::Extent2D right) +{ + return left.mWidth == right.mWidth && left.mHeight == right.mHeight; +} + +bool sameViewRange(const VkImageSubresourceRange& range, VkImageAspectFlags aspect, + std::uint32_t mip_levels) +{ + return range.aspectMask == aspect && range.baseMipLevel == 0 && range.levelCount == mip_levels && + range.baseArrayLayer == 0 && range.layerCount == 1; +} + +bool completeImage(const ImageBinding& binding) +{ + return binding.mImage != VK_NULL_HANDLE && binding.mView != VK_NULL_HANDLE && + binding.mFormat != VK_FORMAT_UNDEFINED && binding.mExtent.mWidth != 0 && + binding.mExtent.mHeight != 0 && binding.mMipLevels != 0 && binding.mUsage != 0 && + binding.mAspect != 0 && binding.mViewRange.levelCount != 0 && + binding.mViewRange.layerCount != 0; +} + +bool completePipeline(const PipelineBinding& binding) +{ + return !binding.mProgram.mName.empty() && binding.mPipeline != VK_NULL_HANDLE && + binding.mLayout != VK_NULL_HANDLE && binding.mRenderPass != VK_NULL_HANDLE && + binding.mFramebuffer != VK_NULL_HANDLE && validMaterialDescriptorSetPair(binding.mDescriptorSets) && + binding.mParameters.mBuffer != VK_NULL_HANDLE && + binding.mParameters.mSize != 0 && + binding.mParameters.mMemory != VK_NULL_HANDLE && binding.mParameters.mMapped != nullptr && + binding.mParameters.mAllocationSize != 0 && nonzeroIdentity(binding.mVertexShaderIdentity) && + nonzeroIdentity(binding.mFragmentShaderIdentity) && !binding.mVertexEntryPoint.empty() && + !binding.mFragmentEntryPoint.empty(); +} + +bool canonicalImage(const ImageBinding& image, VkFormat format, + LLRenderContract::Extent2D extent, std::uint32_t mip_levels, + VkImageUsageFlags required_usage, VkImageAspectFlags aspect, + VkImageLayout layout) +{ + return image.mFormat == format && sameExtent(image.mExtent, extent) && + image.mMipLevels == mip_levels && image.mUsage == required_usage && + image.mAspect == aspect && image.mLayout == layout && + sameViewRange(image.mViewRange, aspect, mip_levels); +} + +bool canonicalSampler(const SamplerBinding& sampler) +{ + return sampler.mMinFilter == VK_FILTER_LINEAR && sampler.mMagFilter == VK_FILTER_LINEAR && + sampler.mMipmapMode == VK_SAMPLER_MIPMAP_MODE_LINEAR && + sampler.mAddressU == VK_SAMPLER_ADDRESS_MODE_REPEAT && + sampler.mAddressV == VK_SAMPLER_ADDRESS_MODE_REPEAT && + sampler.mAddressW == VK_SAMPLER_ADDRESS_MODE_REPEAT && sampler.mMipLodBias == 0.f && + sampler.mAnisotropyEnable == VK_TRUE && sampler.mMaxAnisotropy == 8.f && + sampler.mCompareEnable == VK_FALSE && sampler.mCompareOp == VK_COMPARE_OP_ALWAYS && + sampler.mMinLod == 0.f && sampler.mMaxLod == 2.f && + sampler.mBorderColor == VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK && + sampler.mUnnormalizedCoordinates == VK_FALSE; +} + +bool canonicalSubpassDependencies(const PipelineBinding& pipeline) +{ + constexpr VkPipelineStageFlags ATTACHMENT_STAGES = + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | + VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + constexpr VkAccessFlags ATTACHMENT_ACCESS = + VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + constexpr VkPipelineStageFlags FINAL_STAGES = + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | + VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + constexpr VkAccessFlags FINAL_ACCESS = + VK_ACCESS_SHADER_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + + const SubpassDependencyState& incoming = pipeline.mSubpassDependencies[0]; + const SubpassDependencyState& outgoing = pipeline.mSubpassDependencies[1]; + return incoming.mSourceSubpass == VK_SUBPASS_EXTERNAL && incoming.mDestinationSubpass == 0 && + incoming.mSourceStages == ATTACHMENT_STAGES && + incoming.mDestinationStages == ATTACHMENT_STAGES && incoming.mSourceAccess == 0 && + incoming.mDestinationAccess == ATTACHMENT_ACCESS && incoming.mFlags == 0 && + outgoing.mSourceSubpass == 0 && outgoing.mDestinationSubpass == VK_SUBPASS_EXTERNAL && + outgoing.mSourceStages == ATTACHMENT_STAGES && outgoing.mDestinationStages == FINAL_STAGES && + outgoing.mSourceAccess == ATTACHMENT_ACCESS && outgoing.mDestinationAccess == FINAL_ACCESS && + outgoing.mFlags == 0; +} + +bool canonicalVertexState(const PipelineBinding& pipeline) +{ + constexpr std::array STRIDES{ 16, 16, 8, 4, 16, 8, 8 }; + constexpr std::array FORMATS{ + VK_FORMAT_R32G32B32_SFLOAT, VK_FORMAT_R32G32B32_SFLOAT, VK_FORMAT_R32G32_SFLOAT, + VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R32G32B32A32_SFLOAT, VK_FORMAT_R32G32_SFLOAT, + VK_FORMAT_R32G32_SFLOAT + }; + + for (std::size_t index = 0; index < STRIDES.size(); ++index) + { + const VertexBindingState& binding = pipeline.mVertexBindings[index]; + const VertexAttributeState& attribute = pipeline.mVertexAttributes[index]; + if (binding.mBinding != index || binding.mStride != STRIDES[index] || + binding.mInputRate != VK_VERTEX_INPUT_RATE_VERTEX || attribute.mLocation != index || + attribute.mBinding != index || attribute.mFormat != FORMATS[index] || + attribute.mOffset != 0) + { + return false; + } + } + return true; +} + +bool canonicalRasterState(const PipelineBinding& pipeline) +{ + const RasterState& raster = pipeline.mRaster; + const MultisampleState& multisample = pipeline.mMultisample; + const DepthStencilState& depth = pipeline.mDepthStencil; + + // Vulkan's framebuffer-area equation accounts for the downward native Y + // axis. The negative viewport restores GL's orientation, so the contract's + // counter-clockwise front face remains counter-clockwise here. + if (raster.mTopology != VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST || + raster.mPrimitiveRestartEnable != VK_FALSE || raster.mDepthClampEnable != VK_FALSE || + raster.mRasterizerDiscardEnable != VK_FALSE || raster.mPolygonMode != VK_POLYGON_MODE_FILL || + raster.mCullMode != VK_CULL_MODE_BACK_BIT || raster.mFrontFace != VK_FRONT_FACE_COUNTER_CLOCKWISE || + raster.mDepthBiasEnable != VK_FALSE || raster.mDepthBiasConstantFactor != 0.f || + raster.mDepthBiasClamp != 0.f || raster.mDepthBiasSlopeFactor != 0.f || + raster.mLineWidth != 1.f || + multisample.mRasterizationSamples != VK_SAMPLE_COUNT_1_BIT || + multisample.mSampleShadingEnable != VK_FALSE || + multisample.mMinSampleShading != 0.f || multisample.mSampleMask != 0xffffffffu || + multisample.mAlphaToCoverageEnable != VK_FALSE || multisample.mAlphaToOneEnable != VK_FALSE || + depth.mDepthTestEnable != VK_TRUE || depth.mDepthWriteEnable != VK_TRUE || + depth.mDepthCompareOp != VK_COMPARE_OP_LESS_OR_EQUAL || + depth.mDepthBoundsTestEnable != VK_FALSE || depth.mStencilTestEnable != VK_FALSE || + depth.mFront.failOp != VK_STENCIL_OP_KEEP || depth.mFront.passOp != VK_STENCIL_OP_KEEP || + depth.mFront.depthFailOp != VK_STENCIL_OP_KEEP || + depth.mFront.compareOp != VK_COMPARE_OP_NEVER || depth.mFront.compareMask != 0 || + depth.mFront.writeMask != 0 || depth.mFront.reference != 0 || + depth.mBack.failOp != VK_STENCIL_OP_KEEP || depth.mBack.passOp != VK_STENCIL_OP_KEEP || + depth.mBack.depthFailOp != VK_STENCIL_OP_KEEP || + depth.mBack.compareOp != VK_COMPARE_OP_NEVER || depth.mBack.compareMask != 0 || + depth.mBack.writeMask != 0 || depth.mBack.reference != 0 || + depth.mMinDepthBounds != 0.f || depth.mMaxDepthBounds != 1.f || + pipeline.mLogicOpEnable != VK_FALSE || pipeline.mLogicOp != VK_LOGIC_OP_COPY || + pipeline.mBlendConstants != std::array{} || + pipeline.mDynamicViewport != VK_TRUE || + pipeline.mDynamicScissor != VK_TRUE) + { + return false; + } + + constexpr std::array FORMATS{ VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_R16G16B16A16_UNORM }; + for (std::size_t index = 0; index < FORMATS.size(); ++index) + { + const ColorTargetState& target = pipeline.mColorTargets[index]; + if (target.mFormat != FORMATS[index] || target.mBlendEnable != VK_FALSE || + target.mSrcColorBlendFactor != VK_BLEND_FACTOR_ONE || + target.mDstColorBlendFactor != VK_BLEND_FACTOR_ZERO || + target.mColorBlendOp != VK_BLEND_OP_ADD || + target.mSrcAlphaBlendFactor != VK_BLEND_FACTOR_ONE || + target.mDstAlphaBlendFactor != VK_BLEND_FACTOR_ZERO || + target.mAlphaBlendOp != VK_BLEND_OP_ADD || + target.mWriteMask != MATERIAL_COLOR_WRITE_MASK) + { + return false; + } + } + return true; +} + +bool canonicalPipelineResources(const PipelineBinding& pipeline, + const std::array& sources, + const SamplerBinding& sampler, + const std::array& colors, + const ImageBinding& depth, + const ExecutionContext& context) +{ + if (!sameExtent(pipeline.mExtent, + { LLRenderContract::MATERIAL_FRAME_WIDTH, LLRenderContract::MATERIAL_FRAME_HEIGHT }) || + !validMaterialDescriptorSetPair(pipeline.mDescriptorSets) || + pipeline.mVertexShaderIdentity != context.mRequiredVertexShaderIdentity || + pipeline.mFragmentShaderIdentity != context.mRequiredFragmentShaderIdentity || + pipeline.mVertexEntryPoint != "main" || pipeline.mFragmentEntryPoint != "main" || + pipeline.mParameterDescriptor.mSet != 0 || pipeline.mParameterDescriptor.mBinding != 0 || + pipeline.mParameterDescriptor.mType != VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER || + pipeline.mParameterDescriptor.mStages != + (VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT) || + pipeline.mParameterDescriptor.mBuffer != pipeline.mParameters.mBuffer || + pipeline.mParameterDescriptor.mOffset != 0 || + pipeline.mParameterDescriptor.mRange != MATERIAL_PARAMETER_SIZE || + pipeline.mParameters.mSize != MATERIAL_PARAMETER_SIZE || + pipeline.mParameters.mUsage != VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT || + pipeline.mParameters.mDescriptorOffset != 0 || + pipeline.mParameters.mDescriptorRange != MATERIAL_PARAMETER_SIZE || + pipeline.mParameters.mAllocationSize < MATERIAL_PARAMETER_SIZE || + (pipeline.mParameters.mMemoryProperties & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) == 0) + { + return false; + } + + for (std::size_t index = 0; index < sources.size(); ++index) + { + const SampledDescriptorBinding& descriptor = pipeline.mSampledDescriptors[index]; + if (descriptor.mSet != 1 || descriptor.mBinding != index || + descriptor.mType != VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER || + descriptor.mStages != VK_SHADER_STAGE_FRAGMENT_BIT || + descriptor.mView != sources[index]->mView || descriptor.mSampler != sampler.mSampler || + descriptor.mLayout != VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL || + pipeline.mColorViews[index] != colors[index]->mView || + pipeline.mColorLoadOps[index] != VK_ATTACHMENT_LOAD_OP_CLEAR || + pipeline.mColorStoreOps[index] != VK_ATTACHMENT_STORE_OP_STORE) + { + return false; + } + } + + return pipeline.mDepthView == depth.mView && pipeline.mDepthFormat == VK_FORMAT_D32_SFLOAT && + pipeline.mDepthLoadOp == VK_ATTACHMENT_LOAD_OP_LOAD && + pipeline.mDepthStoreOp == VK_ATTACHMENT_STORE_OP_STORE && + pipeline.mColorInitialLayout == VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL && + pipeline.mColorFinalLayout == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL && + pipeline.mDepthInitialLayout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL && + pipeline.mDepthFinalLayout == VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL && + canonicalSubpassDependencies(pipeline); +} + +template +bool distinctImages(const std::array& images) +{ + for (std::size_t left = 0; left < images.size(); ++left) + { + for (std::size_t right = left + 1; right < images.size(); ++right) + { + if (images[left]->mImage == images[right]->mImage || + images[left]->mView == images[right]->mView) + { + return false; + } + } + } + return true; +} + +struct Prepared +{ + LLRenderContract::MaterialInputs mInputs; + const BufferBinding* mVertex = nullptr; + const BufferBinding* mIndex = nullptr; + std::array mSources{}; + std::array mColors{}; + const ImageBinding* mDepth = nullptr; + const SamplerBinding* mSampler = nullptr; + const PipelineBinding* mPipeline = nullptr; + const LLRenderContract::DrawIndexed* mDraw = nullptr; +}; + +std::optional prepare(const LLRenderContract::FrameSnapshot& frame, + const Registry& registry, const ExecutionContext& context, + std::string& error) +{ + const auto inputs = LLRenderContract::decodeMaterialFrame(frame); + if (!inputs) + { + error = "packet is not the canonical material frame"; + return std::nullopt; + } + if (context.mDevice == VK_NULL_HANDLE || context.mCommandBuffer == VK_NULL_HANDLE || + context.mQueue == VK_NULL_HANDLE || !context.mRecordingAttemptCount || + !context.mSubmissionCount || context.mRecordingAttemptCount == context.mSubmissionCount || + !nonzeroIdentity(context.mRequiredVertexShaderIdentity) || + !nonzeroIdentity(context.mRequiredFragmentShaderIdentity)) + { + error = "execution context is incomplete"; + return std::nullopt; + } + + Prepared result; + result.mInputs = *inputs; + const LLRenderContract::MaterialHandles& handles = inputs->mHandles; + result.mVertex = registry.resolve(handles.mVertexBuffer); + result.mIndex = registry.resolve(handles.mIndexBuffer); + result.mSources = { registry.resolve(handles.mDiffuse), registry.resolve(handles.mNormal), + registry.resolve(handles.mSpecular) }; + result.mColors = { registry.resolve(handles.mGBuffer0), registry.resolve(handles.mGBuffer1), + registry.resolve(handles.mGBuffer2) }; + result.mDepth = registry.resolve(handles.mDepth); + result.mSampler = registry.resolve(handles.mSampler); + result.mPipeline = registry.resolve(handles.mPipeline, frame.mPipelines.front().mProgram); + result.mDraw = &std::get(frame.mPasses.front().mDraws.front()); + + if (!result.mVertex || !result.mIndex || + std::any_of(result.mSources.begin(), result.mSources.end(), [](const auto* value) { return !value; }) || + std::any_of(result.mColors.begin(), result.mColors.end(), [](const auto* value) { return !value; }) || + !result.mDepth || !result.mSampler || !result.mPipeline) + { + error = "registry cannot resolve an exact live resource generation"; + return std::nullopt; + } + + if (result.mVertex->mSize != LLRenderContract::MATERIAL_VERTEX_BUFFER_SIZE || + result.mVertex->mUsage != VK_BUFFER_USAGE_VERTEX_BUFFER_BIT || + result.mIndex->mSize != LLRenderContract::MATERIAL_INDEX_BUFFER_SIZE || + result.mIndex->mUsage != VK_BUFFER_USAGE_INDEX_BUFFER_BIT || + result.mVertex->mBuffer == result.mIndex->mBuffer || + !result.mIndex->mHasTranslatedIndices || + result.mIndex->mTranslatedIndices != MATERIAL_VULKAN_INDICES) + { + error = "registry buffer metadata does not match the material packet"; + return std::nullopt; + } + + constexpr LLRenderContract::Extent2D TEXTURE_EXTENT{ + LLRenderContract::MATERIAL_TEXTURE_WIDTH, LLRenderContract::MATERIAL_TEXTURE_HEIGHT + }; + constexpr LLRenderContract::Extent2D FRAME_EXTENT{ + LLRenderContract::MATERIAL_FRAME_WIDTH, LLRenderContract::MATERIAL_FRAME_HEIGHT + }; + for (const ImageBinding* source : result.mSources) + { + if (!canonicalImage(*source, VK_FORMAT_R8G8B8A8_UNORM, TEXTURE_EXTENT, + LLRenderContract::MATERIAL_TEXTURE_MIP_LEVELS, MATERIAL_SOURCE_USAGE, + VK_IMAGE_ASPECT_COLOR_BIT, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL)) + { + error = "source image metadata does not match the material packet"; + return std::nullopt; + } + } + constexpr std::array COLOR_FORMATS{ VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_R16G16B16A16_UNORM }; + for (std::size_t index = 0; index < result.mColors.size(); ++index) + { + if (!canonicalImage(*result.mColors[index], COLOR_FORMATS[index], FRAME_EXTENT, 1, + MATERIAL_COLOR_USAGE, VK_IMAGE_ASPECT_COLOR_BIT, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL)) + { + error = "color attachment metadata does not match the material packet"; + return std::nullopt; + } + } + if (!canonicalImage(*result.mDepth, VK_FORMAT_D32_SFLOAT, FRAME_EXTENT, 1, + MATERIAL_DEPTH_USAGE, VK_IMAGE_ASPECT_DEPTH_BIT, + VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL)) + { + error = "depth attachment is not the fixed D32 material substitution"; + return std::nullopt; + } + + std::array all_images{ result.mSources[0], result.mSources[1], + result.mSources[2], result.mColors[0], + result.mColors[1], result.mColors[2], + result.mDepth }; + if (!distinctImages(all_images)) + { + error = "material images or views alias"; + return std::nullopt; + } + if (!canonicalSampler(*result.mSampler)) + { + error = "sampler metadata does not match the material packet"; + return std::nullopt; + } + + const PipelineBinding& pipeline = *result.mPipeline; + if (!canonicalPipelineResources(pipeline, result.mSources, *result.mSampler, + result.mColors, *result.mDepth, context)) + { + error = "pipeline resources or shader identity do not match the material packet"; + return std::nullopt; + } + if (!canonicalVertexState(pipeline) || pipeline.mIndexType != VK_INDEX_TYPE_UINT16) + { + error = "pipeline vertex or index state does not match the material packet"; + return std::nullopt; + } + if (!canonicalRasterState(pipeline)) + { + error = "pipeline raster, depth, multisample, or color state does not match the material packet"; + return std::nullopt; + } + + return result; +} + +} // namespace + +bool Registry::addBuffer(LLRenderContract::BufferHandle handle, BufferBinding binding) +{ + const bool complete = binding.mBuffer != VK_NULL_HANDLE && binding.mSize != 0 && binding.mUsage != 0; + return addEntry(mBuffers, handle, std::move(binding), complete); +} + +bool Registry::addImage(LLRenderContract::ImageHandle handle, ImageBinding binding) +{ + const bool complete = completeImage(binding); + return addEntry(mImages, handle, std::move(binding), complete); +} + +bool Registry::addSampler(LLRenderContract::SamplerHandle handle, SamplerBinding binding) +{ + const bool complete = binding.mSampler != VK_NULL_HANDLE; + return addEntry(mSamplers, handle, std::move(binding), complete); +} + +bool Registry::addPipeline(LLRenderContract::PipelineHandle handle, PipelineBinding binding) +{ + const bool complete = completePipeline(binding); + return addEntry(mPipelines, handle, std::move(binding), complete); +} + +const BufferBinding* Registry::resolve(LLRenderContract::BufferHandle handle) const +{ + return resolveEntry(mBuffers, handle); +} + +const ImageBinding* Registry::resolve(LLRenderContract::ImageHandle handle) const +{ + return resolveEntry(mImages, handle); +} + +const SamplerBinding* Registry::resolve(LLRenderContract::SamplerHandle handle) const +{ + return resolveEntry(mSamplers, handle); +} + +const PipelineBinding* Registry::resolve(LLRenderContract::PipelineHandle handle, + const LLRenderContract::ShaderProgramKey& program) const +{ + const PipelineBinding* binding = resolveEntry(mPipelines, handle); + if (!binding || binding->mProgram.mName != program.mName || + binding->mProgram.mVariant != program.mVariant) + { + return nullptr; + } + return binding; +} + +bool execute(const LLRenderContract::FrameSnapshot& frame, const Registry& registry, + const ExecutionContext& context, std::string& error) +{ + const auto prepared = prepare(frame, registry, context, error); + if (!prepared) + { + return false; + } + + static_assert(std::is_trivially_copyable_v); + std::memcpy(prepared->mPipeline->mParameters.mMapped, &prepared->mInputs.mParameters, + MATERIAL_PARAMETER_SIZE); + + if ((prepared->mPipeline->mParameters.mMemoryProperties & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0) + { + VkMappedMemoryRange mapped_range{}; + mapped_range.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE; + mapped_range.memory = prepared->mPipeline->mParameters.mMemory; + mapped_range.offset = 0; + mapped_range.size = VK_WHOLE_SIZE; + const VkResult flush_result = vkFlushMappedMemoryRanges(context.mDevice, 1, &mapped_range); + if (flush_result != VK_SUCCESS) + { + error = "vkFlushMappedMemoryRanges failed"; + return false; + } + } + + ++*context.mRecordingAttemptCount; + VkResult result = vkResetCommandBuffer(context.mCommandBuffer, 0); + if (result != VK_SUCCESS) + { + error = "vkResetCommandBuffer failed"; + return false; + } + + VkCommandBufferBeginInfo begin{}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + result = vkBeginCommandBuffer(context.mCommandBuffer, &begin); + if (result != VK_SUCCESS) + { + error = "vkBeginCommandBuffer failed"; + return false; + } + + VkBufferMemoryBarrier parameter_barrier{}; + parameter_barrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; + parameter_barrier.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT; + parameter_barrier.dstAccessMask = VK_ACCESS_UNIFORM_READ_BIT; + parameter_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + parameter_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + parameter_barrier.buffer = prepared->mPipeline->mParameters.mBuffer; + parameter_barrier.offset = 0; + parameter_barrier.size = MATERIAL_PARAMETER_SIZE; + vkCmdPipelineBarrier(context.mCommandBuffer, VK_PIPELINE_STAGE_HOST_BIT, + VK_PIPELINE_STAGE_VERTEX_SHADER_BIT | VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + 0, 0, nullptr, 1, ¶meter_barrier, 0, nullptr); + + std::array color_barriers{}; + for (std::size_t index = 0; index < color_barriers.size(); ++index) + { + VkImageMemoryBarrier& barrier = color_barriers[index]; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + // The diagnostic's sentinel snapshot leaves each target in transfer-src. + // The contract's Undefined state means discard, so no source access is + // carried into the attachment transition even though the live layout is + // recorded truthfully. + barrier.oldLayout = prepared->mColors[index]->mLayout; + barrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = prepared->mColors[index]->mImage; + barrier.subresourceRange = prepared->mColors[index]->mViewRange; + } + vkCmdPipelineBarrier(context.mCommandBuffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, 0, 0, nullptr, + 0, nullptr, static_cast(color_barriers.size()), + color_barriers.data()); + + std::array clear_values{}; + VkRenderPassBeginInfo render_pass{}; + render_pass.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + render_pass.renderPass = prepared->mPipeline->mRenderPass; + render_pass.framebuffer = prepared->mPipeline->mFramebuffer; + render_pass.renderArea.extent = { LLRenderContract::MATERIAL_FRAME_WIDTH, + LLRenderContract::MATERIAL_FRAME_HEIGHT }; + render_pass.clearValueCount = static_cast(clear_values.size()); + render_pass.pClearValues = clear_values.data(); + vkCmdBeginRenderPass(context.mCommandBuffer, &render_pass, VK_SUBPASS_CONTENTS_INLINE); + + VkViewport viewport{ 0.f, static_cast(LLRenderContract::MATERIAL_FRAME_HEIGHT), + static_cast(LLRenderContract::MATERIAL_FRAME_WIDTH), + -static_cast(LLRenderContract::MATERIAL_FRAME_HEIGHT), 0.f, 1.f }; + VkRect2D scissor{ { 0, 0 }, + { LLRenderContract::MATERIAL_FRAME_WIDTH, + LLRenderContract::MATERIAL_FRAME_HEIGHT } }; + vkCmdSetViewport(context.mCommandBuffer, 0, 1, &viewport); + vkCmdSetScissor(context.mCommandBuffer, 0, 1, &scissor); + vkCmdBindPipeline(context.mCommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, + prepared->mPipeline->mPipeline); + vkCmdBindDescriptorSets(context.mCommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, + prepared->mPipeline->mLayout, 0, + static_cast(prepared->mPipeline->mDescriptorSets.size()), + prepared->mPipeline->mDescriptorSets.data(), 0, nullptr); + + std::array vertex_buffers{}; + std::array vertex_offsets{}; + for (std::size_t index = 0; index < vertex_buffers.size(); ++index) + { + vertex_buffers[index] = prepared->mVertex->mBuffer; + vertex_offsets[index] = prepared->mDraw->mResources.mVertexBuffers[index].mOffset; + } + vkCmdBindVertexBuffers(context.mCommandBuffer, 0, + static_cast(vertex_buffers.size()), + vertex_buffers.data(), vertex_offsets.data()); + vkCmdBindIndexBuffer(context.mCommandBuffer, prepared->mIndex->mBuffer, 0, + VK_INDEX_TYPE_UINT16); + vkCmdDrawIndexed(context.mCommandBuffer, prepared->mDraw->mIndexCount, + prepared->mDraw->mInstanceCount, prepared->mDraw->mFirstIndex, + prepared->mDraw->mBaseVertex, prepared->mDraw->mFirstInstance); + vkCmdEndRenderPass(context.mCommandBuffer); + + result = vkEndCommandBuffer(context.mCommandBuffer); + if (result != VK_SUCCESS) + { + error = "vkEndCommandBuffer failed"; + return false; + } + + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &context.mCommandBuffer; + result = vkQueueSubmit(context.mQueue, 1, &submit, VK_NULL_HANDLE); + if (result != VK_SUCCESS) + { + error = "vkQueueSubmit failed"; + return false; + } + ++*context.mSubmissionCount; + result = vkQueueWaitIdle(context.mQueue); + if (result != VK_SUCCESS) + { + error = "vkQueueWaitIdle failed"; + return false; + } + + error.clear(); + return true; +} + +} // namespace LLRenderVulkanMaterial diff --git a/indra/llrender/vulkan/llrendervulkanmaterial.h b/indra/llrender/vulkan/llrendervulkanmaterial.h new file mode 100644 index 00000000000..2631cb99eb6 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterial.h @@ -0,0 +1,297 @@ +/** + * @file llrendervulkanmaterial.h + * @brief Narrow Vulkan registry and executor for the Stage 12 material packet. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANMATERIAL_H +#define LL_LLRENDERVULKANMATERIAL_H + +#include "llmaterialcontract.h" + +#include + +#include +#include +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ + +inline constexpr std::array MATERIAL_VULKAN_INDICES{ 2, 0, 1, 3, 0, 2 }; +inline constexpr std::size_t MATERIAL_PARAMETER_SIZE = sizeof(LLRenderContract::MaterialParameters); + +struct BufferBinding +{ + VkBuffer mBuffer = VK_NULL_HANDLE; + VkDeviceSize mSize = 0; + VkBufferUsageFlags mUsage = 0; + + // Set only for the immutable diagnostic index allocation. The cyclic + // rotation makes Vulkan's first provoking vertex equal GL's last vertex. + bool mHasTranslatedIndices = false; + std::array mTranslatedIndices{}; +}; + +struct ImageBinding +{ + VkImage mImage = VK_NULL_HANDLE; + VkImageView mView = VK_NULL_HANDLE; + VkFormat mFormat = VK_FORMAT_UNDEFINED; + LLRenderContract::Extent2D mExtent; + std::uint32_t mMipLevels = 0; + VkImageUsageFlags mUsage = 0; + VkImageAspectFlags mAspect = 0; + VkImageLayout mLayout = VK_IMAGE_LAYOUT_UNDEFINED; + VkImageSubresourceRange mViewRange{}; +}; + +struct SamplerBinding +{ + VkSampler mSampler = VK_NULL_HANDLE; + VkFilter mMinFilter = VK_FILTER_NEAREST; + VkFilter mMagFilter = VK_FILTER_NEAREST; + VkSamplerMipmapMode mMipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + VkSamplerAddressMode mAddressU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + VkSamplerAddressMode mAddressV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + VkSamplerAddressMode mAddressW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + float mMipLodBias = 0.f; + VkBool32 mAnisotropyEnable = VK_FALSE; + float mMaxAnisotropy = 1.f; + VkBool32 mCompareEnable = VK_FALSE; + VkCompareOp mCompareOp = VK_COMPARE_OP_ALWAYS; + float mMinLod = 0.f; + float mMaxLod = 0.f; + VkBorderColor mBorderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK; + VkBool32 mUnnormalizedCoordinates = VK_FALSE; +}; + +// mMapped points at byte zero of mMemory. The diagnostic binds mBuffer at +// memory offset zero and keeps the whole allocation mapped for the run. +struct ParameterBinding +{ + VkBuffer mBuffer = VK_NULL_HANDLE; + VkDeviceSize mSize = 0; + VkBufferUsageFlags mUsage = 0; + VkDeviceMemory mMemory = VK_NULL_HANDLE; + void* mMapped = nullptr; + VkDeviceSize mAllocationSize = 0; + VkDeviceSize mDescriptorOffset = 0; + VkDeviceSize mDescriptorRange = 0; + VkMemoryPropertyFlags mMemoryProperties = 0; +}; + +struct UniformDescriptorBinding +{ + std::uint32_t mSet = 0; + std::uint32_t mBinding = 0; + VkDescriptorType mType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + VkShaderStageFlags mStages = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + VkBuffer mBuffer = VK_NULL_HANDLE; + VkDeviceSize mOffset = 0; + VkDeviceSize mRange = 0; +}; + +struct SampledDescriptorBinding +{ + std::uint32_t mSet = 0; + std::uint32_t mBinding = 0; + VkDescriptorType mType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + VkShaderStageFlags mStages = VK_SHADER_STAGE_FRAGMENT_BIT; + VkImageView mView = VK_NULL_HANDLE; + VkSampler mSampler = VK_NULL_HANDLE; + VkImageLayout mLayout = VK_IMAGE_LAYOUT_UNDEFINED; +}; + +struct VertexBindingState +{ + std::uint32_t mBinding = 0; + std::uint32_t mStride = 0; + VkVertexInputRate mInputRate = VK_VERTEX_INPUT_RATE_VERTEX; +}; + +struct VertexAttributeState +{ + std::uint32_t mLocation = 0; + std::uint32_t mBinding = 0; + VkFormat mFormat = VK_FORMAT_UNDEFINED; + std::uint32_t mOffset = 0; +}; + +struct RasterState +{ + VkPrimitiveTopology mTopology = VK_PRIMITIVE_TOPOLOGY_MAX_ENUM; + VkBool32 mPrimitiveRestartEnable = VK_FALSE; + VkBool32 mDepthClampEnable = VK_FALSE; + VkBool32 mRasterizerDiscardEnable = VK_FALSE; + VkPolygonMode mPolygonMode = VK_POLYGON_MODE_MAX_ENUM; + VkCullModeFlags mCullMode = VK_CULL_MODE_NONE; + VkFrontFace mFrontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + VkBool32 mDepthBiasEnable = VK_FALSE; + float mDepthBiasConstantFactor = 0.f; + float mDepthBiasClamp = 0.f; + float mDepthBiasSlopeFactor = 0.f; + float mLineWidth = 0.f; +}; + +struct MultisampleState +{ + VkSampleCountFlagBits mRasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + VkBool32 mSampleShadingEnable = VK_FALSE; + float mMinSampleShading = 0.f; + VkSampleMask mSampleMask = 0xffffffffu; + VkBool32 mAlphaToCoverageEnable = VK_FALSE; + VkBool32 mAlphaToOneEnable = VK_FALSE; +}; + +struct DepthStencilState +{ + VkBool32 mDepthTestEnable = VK_FALSE; + VkBool32 mDepthWriteEnable = VK_FALSE; + VkCompareOp mDepthCompareOp = VK_COMPARE_OP_ALWAYS; + VkBool32 mDepthBoundsTestEnable = VK_FALSE; + VkBool32 mStencilTestEnable = VK_FALSE; + VkStencilOpState mFront{}; + VkStencilOpState mBack{}; + float mMinDepthBounds = 0.f; + float mMaxDepthBounds = 1.f; +}; + +struct ColorTargetState +{ + VkFormat mFormat = VK_FORMAT_UNDEFINED; + VkBool32 mBlendEnable = VK_FALSE; + VkBlendFactor mSrcColorBlendFactor = VK_BLEND_FACTOR_ONE; + VkBlendFactor mDstColorBlendFactor = VK_BLEND_FACTOR_ZERO; + VkBlendOp mColorBlendOp = VK_BLEND_OP_ADD; + VkBlendFactor mSrcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + VkBlendFactor mDstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; + VkBlendOp mAlphaBlendOp = VK_BLEND_OP_ADD; + VkColorComponentFlags mWriteMask = 0; +}; + +struct SubpassDependencyState +{ + std::uint32_t mSourceSubpass = VK_SUBPASS_EXTERNAL; + std::uint32_t mDestinationSubpass = VK_SUBPASS_EXTERNAL; + VkPipelineStageFlags mSourceStages = 0; + VkPipelineStageFlags mDestinationStages = 0; + VkAccessFlags mSourceAccess = 0; + VkAccessFlags mDestinationAccess = 0; + VkDependencyFlags mFlags = 0; +}; + +// SHA-256 of the validated SPIR-V module bytes. The runner records the token +// when it creates the immutable pipeline and supplies the required token again +// through ExecutionContext. +using ShaderIdentityToken = std::array; + +struct PipelineBinding +{ + LLRenderContract::ShaderProgramKey mProgram; + VkPipeline mPipeline = VK_NULL_HANDLE; + VkPipelineLayout mLayout = VK_NULL_HANDLE; + VkRenderPass mRenderPass = VK_NULL_HANDLE; + VkFramebuffer mFramebuffer = VK_NULL_HANDLE; + LLRenderContract::Extent2D mExtent; + + std::array mDescriptorSets{}; + UniformDescriptorBinding mParameterDescriptor; + std::array mSampledDescriptors{}; + ParameterBinding mParameters; + + std::array mColorViews{}; + VkImageView mDepthView = VK_NULL_HANDLE; + VkFormat mDepthFormat = VK_FORMAT_UNDEFINED; + std::array mColorLoadOps{}; + std::array mColorStoreOps{}; + VkAttachmentLoadOp mDepthLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + VkAttachmentStoreOp mDepthStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + std::array mSubpassDependencies{}; + VkImageLayout mColorInitialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + VkImageLayout mColorFinalLayout = VK_IMAGE_LAYOUT_UNDEFINED; + VkImageLayout mDepthInitialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + VkImageLayout mDepthFinalLayout = VK_IMAGE_LAYOUT_UNDEFINED; + + std::array mVertexBindings{}; + std::array mVertexAttributes{}; + VkIndexType mIndexType = VK_INDEX_TYPE_MAX_ENUM; + RasterState mRaster; + MultisampleState mMultisample; + DepthStencilState mDepthStencil; + std::array mColorTargets{}; + VkBool32 mLogicOpEnable = VK_FALSE; + VkLogicOp mLogicOp = VK_LOGIC_OP_COPY; + std::array mBlendConstants{}; + VkBool32 mDynamicViewport = VK_FALSE; + VkBool32 mDynamicScissor = VK_FALSE; + + ShaderIdentityToken mVertexShaderIdentity{}; + ShaderIdentityToken mFragmentShaderIdentity{}; + std::string mVertexEntryPoint = "main"; + std::string mFragmentEntryPoint = "main"; +}; + +// Vulkan non-dispatchable handle values are opaque and may compare equal. +// A canonical pair requires two non-null values, not two distinct values. +bool validMaterialDescriptorSetPair(const std::array& descriptor_sets) noexcept; + +// The registry borrows immutable objects owned by one synchronous offscreen run. +class Registry +{ +public: + bool addBuffer(LLRenderContract::BufferHandle handle, BufferBinding binding); + bool addImage(LLRenderContract::ImageHandle handle, ImageBinding binding); + bool addSampler(LLRenderContract::SamplerHandle handle, SamplerBinding binding); + bool addPipeline(LLRenderContract::PipelineHandle handle, PipelineBinding binding); + + const BufferBinding* resolve(LLRenderContract::BufferHandle handle) const; + const ImageBinding* resolve(LLRenderContract::ImageHandle handle) const; + const SamplerBinding* resolve(LLRenderContract::SamplerHandle handle) const; + const PipelineBinding* resolve(LLRenderContract::PipelineHandle handle, + const LLRenderContract::ShaderProgramKey& program) const; + +private: + template + struct Entry + { + HandleType mHandle; + BindingType mBinding; + }; + + std::vector> mBuffers; + std::vector> mImages; + std::vector> mSamplers; + std::vector> mPipelines; +}; + +struct ExecutionContext +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkCommandBuffer mCommandBuffer = VK_NULL_HANDLE; + VkQueue mQueue = VK_NULL_HANDLE; + std::uint64_t* mRecordingAttemptCount = nullptr; + std::uint64_t* mSubmissionCount = nullptr; + ShaderIdentityToken mRequiredVertexShaderIdentity{}; + ShaderIdentityToken mRequiredFragmentShaderIdentity{}; +}; + +// Performs all packet, handle, immutable metadata, and physical-resource +// checks before copying parameter bytes or attempting command recording. +bool execute(const LLRenderContract::FrameSnapshot& frame, const Registry& registry, + const ExecutionContext& context, std::string& error); + +} // namespace LLRenderVulkanMaterial + +#endif // LL_LLRENDERVULKANMATERIAL_H diff --git a/indra/llrender/vulkan/llrendervulkanmaterialattachment.cpp b/indra/llrender/vulkan/llrendervulkanmaterialattachment.cpp new file mode 100644 index 00000000000..6c57da64655 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialattachment.cpp @@ -0,0 +1,246 @@ +/** + * @file llrendervulkanmaterialattachment.cpp + * @brief Portable Vulkan attachment profiles for the canonical material pass. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkanmaterialattachment.h" + +namespace LLRenderVulkanMaterial +{ + +struct MaterialAttachmentProfileFactory +{ + static LegacyNormSpecAttachmentProfile create(LLRenderContract::LegacyNormSpecTargetProfile target_profile, + VkPhysicalDevice physical_device) noexcept + { + return LegacyNormSpecAttachmentProfile(target_profile, physical_device); + } + + static MaterialColorAttachmentProfile& color(LegacyNormSpecAttachmentProfile& profile, std::uint32_t slot) noexcept + { + return profile.mColors[slot]; + } + + static MaterialDepthAttachmentProfile& depth(LegacyNormSpecAttachmentProfile& profile) noexcept { return profile.mDepth; } +}; + +namespace +{ + using namespace LLRenderContract; + + inline constexpr VkImageUsageFlags COLOR_USAGE = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + inline constexpr VkImageUsageFlags DEPTH_USAGE = VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT; + inline constexpr VkFormatFeatureFlags COLOR_FEATURES = VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT; + inline constexpr VkFormatFeatureFlags DEPTH_FEATURES = + VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT; + inline constexpr VkColorComponentFlags RGBA_WRITE_MASK = + VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + inline constexpr VkColorComponentFlags RGB_WRITE_MASK = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | VK_COLOR_COMPONENT_B_BIT; + + inline constexpr std::array MODERN_HDR_FORMATS{ + VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R16G16B16A16_UNORM, VK_FORMAT_R16G16B16A16_SFLOAT + }; + + inline constexpr std::array COMPATIBILITY_FORMATS{ + VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_A2B10G10R10_UNORM_PACK32, VK_FORMAT_R8G8B8A8_UNORM + }; + + MaterialAttachmentResolutionError failure(MaterialAttachmentResolutionCode code) noexcept + { + MaterialAttachmentResolutionError error; + error.mCode = code; + return error; + } + + MaterialAttachmentResolutionError limitFailure(MaterialAttachmentLimit limit, std::uint32_t available) noexcept + { + MaterialAttachmentResolutionError error = failure(MaterialAttachmentResolutionCode::InsufficientLimit); + error.mQuery = MaterialAttachmentQuery::PhysicalDeviceProperties; + error.mLimit = limit; + error.mRequiredLimit = static_cast(LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT); + error.mAvailableLimit = available; + return error; + } + + MaterialAttachmentResolutionError deviceFeatureFailure(MaterialAttachmentFeature feature) noexcept + { + MaterialAttachmentResolutionError error = failure(MaterialAttachmentResolutionCode::MissingDeviceFeature); + error.mQuery = MaterialAttachmentQuery::PhysicalDeviceFeatures; + error.mFeature = feature; + return error; + } + + MaterialAttachmentResolutionError attachmentFailure(MaterialAttachmentResolutionCode code, MaterialAttachmentKind kind, + std::optional color_slot, PixelFormat logical_format, + VkFormat native_format, VkFormatFeatureFlags required, + VkFormatFeatureFlags available) noexcept + { + MaterialAttachmentResolutionError error = failure(code); + error.mAttachment = kind; + error.mColorSlot = color_slot; + error.mLogicalFormat = logical_format; + error.mNativeFormat = native_format; + error.mRequiredFeatures = required; + error.mAvailableFeatures = available; + return error; + } + + std::optional resolveCapabilities(const MaterialAttachmentDevice& device, + MaterialAttachmentKind kind, + std::optional color_slot, + PixelFormat logical_format, VkFormat native_format, + VkImageUsageFlags usage, VkFormatFeatureFlags required_features, + VkImageFormatProperties& resolved) noexcept + { + VkFormatProperties format_properties{}; + device.mDispatch.mGetPhysicalDeviceFormatProperties(device.mPhysicalDevice, native_format, &format_properties); + const VkFormatFeatureFlags available_features = format_properties.optimalTilingFeatures; + if ((available_features & required_features) != required_features) + { + auto error = attachmentFailure(MaterialAttachmentResolutionCode::MissingFormatFeatures, kind, color_slot, logical_format, + native_format, required_features, available_features); + error.mQuery = MaterialAttachmentQuery::FormatProperties; + return error; + } + + VkImageFormatProperties image_properties{}; + const VkResult result = device.mDispatch.mGetPhysicalDeviceImageFormatProperties( + device.mPhysicalDevice, native_format, VK_IMAGE_TYPE_2D, VK_IMAGE_TILING_OPTIMAL, usage, 0, &image_properties); + if (result != VK_SUCCESS) + { + auto error = attachmentFailure(MaterialAttachmentResolutionCode::ImageFormatQueryFailure, kind, color_slot, logical_format, + native_format, required_features, available_features); + error.mQuery = MaterialAttachmentQuery::ImageFormatProperties; + error.mResult = result; + return error; + } + + auto insufficient = [&](MaterialAttachmentCapability capability, std::uint64_t available) + { + auto error = attachmentFailure(MaterialAttachmentResolutionCode::InsufficientImageCapability, kind, color_slot, logical_format, + native_format, required_features, available_features); + error.mQuery = MaterialAttachmentQuery::ImageFormatProperties; + error.mCapability = capability; + error.mRequiredCapability = 1; + error.mAvailableCapability = available; + return std::optional{ error }; + }; + + if (image_properties.maxMipLevels < 1) + { + return insufficient(MaterialAttachmentCapability::MipLevels, image_properties.maxMipLevels); + } + if (image_properties.maxArrayLayers < 1) + { + return insufficient(MaterialAttachmentCapability::ArrayLayers, image_properties.maxArrayLayers); + } + if (image_properties.maxExtent.width < 1) + { + return insufficient(MaterialAttachmentCapability::ExtentWidth, image_properties.maxExtent.width); + } + if (image_properties.maxExtent.height < 1) + { + return insufficient(MaterialAttachmentCapability::ExtentHeight, image_properties.maxExtent.height); + } + if (image_properties.maxExtent.depth < 1) + { + return insufficient(MaterialAttachmentCapability::ExtentDepth, image_properties.maxExtent.depth); + } + if ((image_properties.sampleCounts & VK_SAMPLE_COUNT_1_BIT) == 0) + { + return insufficient(MaterialAttachmentCapability::SampleCountOne, image_properties.sampleCounts); + } + + resolved = image_properties; + return std::nullopt; + } + +} // namespace + +MaterialAttachmentResolutionResult resolveLegacyNormSpecAttachmentProfile(const MaterialAttachmentDevice& device, + const LegacyNormSpecPipelineKey& pipeline_key) noexcept +{ + if (device.mPhysicalDevice == VK_NULL_HANDLE) + { + return failure(MaterialAttachmentResolutionCode::InvalidPhysicalDevice); + } + if (!device.mDispatch.mGetPhysicalDeviceFeatures || !device.mDispatch.mGetPhysicalDeviceProperties || + !device.mDispatch.mGetPhysicalDeviceFormatProperties || !device.mDispatch.mGetPhysicalDeviceImageFormatProperties) + { + return failure(MaterialAttachmentResolutionCode::InvalidDispatch); + } + if (!validLegacyNormSpecPipelineKey(pipeline_key)) + { + return failure(MaterialAttachmentResolutionCode::InvalidPipelineKey); + } + if (pipeline_key.mTargetProfile != LegacyNormSpecTargetProfile::ModernHDR && + pipeline_key.mTargetProfile != LegacyNormSpecTargetProfile::Compatibility) + { + return failure(MaterialAttachmentResolutionCode::UnsupportedTargetProfile); + } + + VkPhysicalDeviceFeatures features{}; + device.mDispatch.mGetPhysicalDeviceFeatures(device.mPhysicalDevice, &features); + if (features.independentBlend != VK_TRUE) + { + return deviceFeatureFailure(MaterialAttachmentFeature::IndependentBlend); + } + + VkPhysicalDeviceProperties properties{}; + device.mDispatch.mGetPhysicalDeviceProperties(device.mPhysicalDevice, &properties); + if (properties.limits.maxColorAttachments < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT) + { + return limitFailure(MaterialAttachmentLimit::ColorAttachments, properties.limits.maxColorAttachments); + } + if (properties.limits.maxFragmentOutputAttachments < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT) + { + return limitFailure(MaterialAttachmentLimit::FragmentOutputs, properties.limits.maxFragmentOutputAttachments); + } + + const auto& native_formats = + pipeline_key.mTargetProfile == LegacyNormSpecTargetProfile::ModernHDR ? MODERN_HDR_FORMATS : COMPATIBILITY_FORMATS; + + LegacyNormSpecAttachmentProfile profile = MaterialAttachmentProfileFactory::create(pipeline_key.mTargetProfile, device.mPhysicalDevice); + for (std::uint32_t slot = 0; slot < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; ++slot) + { + MaterialColorAttachmentProfile& color = MaterialAttachmentProfileFactory::color(profile, slot); + color.mLogicalFormat = pipeline_key.mColorTargets[slot].mFormat; + color.mNativeFormat = native_formats[slot]; + color.mUsage = COLOR_USAGE; + color.mRequiredFeatures = COLOR_FEATURES; + color.mWriteMask = slot == 3 ? RGB_WRITE_MASK : RGBA_WRITE_MASK; + color.mAlphaSemantic = slot == 3 ? MaterialAttachmentAlphaSemantic::ImplicitOneAfterClear : MaterialAttachmentAlphaSemantic::Stored; + color.mClearColor = slot == 3 ? std::array{ 0.f, 0.f, 0.f, 1.f } : std::array{ 0.f, 0.f, 0.f, 0.f }; + + if (auto error = resolveCapabilities(device, MaterialAttachmentKind::Color, slot, color.mLogicalFormat, color.mNativeFormat, + color.mUsage, color.mRequiredFeatures, color.mCapabilities)) + { + return *error; + } + } + + MaterialDepthAttachmentProfile& depth = MaterialAttachmentProfileFactory::depth(profile); + depth.mLogicalFormat = *pipeline_key.mDepthFormat; + depth.mNativeFormat = VK_FORMAT_D32_SFLOAT; + depth.mUsage = DEPTH_USAGE; + depth.mRequiredFeatures = DEPTH_FEATURES; + if (auto error = resolveCapabilities(device, MaterialAttachmentKind::Depth, std::nullopt, depth.mLogicalFormat, depth.mNativeFormat, + depth.mUsage, depth.mRequiredFeatures, depth.mCapabilities)) + { + return *error; + } + + return profile; +} + +} // namespace LLRenderVulkanMaterial diff --git a/indra/llrender/vulkan/llrendervulkanmaterialattachment.h b/indra/llrender/vulkan/llrendervulkanmaterialattachment.h new file mode 100644 index 00000000000..962e770af8c --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialattachment.h @@ -0,0 +1,221 @@ +/** + * @file llrendervulkanmaterialattachment.h + * @brief Portable Vulkan attachment profiles for the canonical material pass. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANMATERIALATTACHMENT_H +#define LL_LLRENDERVULKANMATERIALATTACHMENT_H + +#include "lldrawpacketcontract.h" + +#include + +#include +#include +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ + +inline constexpr std::uint32_t LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT = 4; + +struct MaterialAttachmentDispatch +{ + PFN_vkGetPhysicalDeviceFeatures mGetPhysicalDeviceFeatures = nullptr; + PFN_vkGetPhysicalDeviceProperties mGetPhysicalDeviceProperties = nullptr; + PFN_vkGetPhysicalDeviceFormatProperties mGetPhysicalDeviceFormatProperties = nullptr; + PFN_vkGetPhysicalDeviceImageFormatProperties mGetPhysicalDeviceImageFormatProperties = nullptr; +}; + +// The Vulkan 1.1-or-newer physical device and the implementation addressed by +// these callbacks remain owned by the caller. The resolved profile contains no +// native object and is valid only for this physical device. Feature resolution +// proves physical support, not what an existing logical device enabled. +struct MaterialAttachmentDevice +{ + VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE; + MaterialAttachmentDispatch mDispatch; +}; + +enum class MaterialAttachmentKind : std::uint8_t +{ + Color, + Depth +}; + +enum class MaterialAttachmentQuery : std::uint8_t +{ + PhysicalDeviceFeatures, + PhysicalDeviceProperties, + FormatProperties, + ImageFormatProperties +}; + +enum class MaterialAttachmentFeature : std::uint8_t +{ + IndependentBlend +}; + +enum class MaterialAttachmentLimit : std::uint8_t +{ + ColorAttachments, + FragmentOutputs +}; + +enum class MaterialAttachmentCapability : std::uint8_t +{ + MipLevels, + ArrayLayers, + ExtentWidth, + ExtentHeight, + ExtentDepth, + SampleCountOne +}; + +enum class MaterialAttachmentAlphaSemantic : std::uint8_t +{ + Stored, + ImplicitOneAfterClear +}; + +enum class MaterialAttachmentResolutionCode : std::uint8_t +{ + InvalidPhysicalDevice, + InvalidDispatch, + InvalidPipelineKey, + UnsupportedTargetProfile, + MissingDeviceFeature, + InsufficientLimit, + MissingFormatFeatures, + ImageFormatQueryFailure, + InsufficientImageCapability +}; + +struct MaterialAttachmentResolutionError +{ + MaterialAttachmentResolutionCode mCode = MaterialAttachmentResolutionCode::InvalidPhysicalDevice; + std::optional mQuery; + std::optional mFeature; + std::optional mLimit; + std::optional mCapability; + std::optional mAttachment; + std::optional mColorSlot; + std::optional mLogicalFormat; + VkFormat mNativeFormat = VK_FORMAT_UNDEFINED; + VkFormatFeatureFlags mRequiredFeatures = 0; + VkFormatFeatureFlags mAvailableFeatures = 0; + std::uint32_t mRequiredLimit = 0; + std::uint32_t mAvailableLimit = 0; + std::uint64_t mRequiredCapability = 0; + std::uint64_t mAvailableCapability = 0; + VkResult mResult = VK_SUCCESS; + + friend constexpr bool operator==(const MaterialAttachmentResolutionError&, const MaterialAttachmentResolutionError&) = default; +}; + +class MaterialAttachmentDeviceRequirements +{ +public: + MaterialAttachmentDeviceRequirements(const MaterialAttachmentDeviceRequirements&) = default; + MaterialAttachmentDeviceRequirements& operator=(const MaterialAttachmentDeviceRequirements&) = default; + MaterialAttachmentDeviceRequirements(MaterialAttachmentDeviceRequirements&&) = default; + MaterialAttachmentDeviceRequirements& operator=(MaterialAttachmentDeviceRequirements&&) = default; + + bool independentBlendRequired() const noexcept { return mIndependentBlendRequired; } + + friend constexpr bool operator==(const MaterialAttachmentDeviceRequirements&, const MaterialAttachmentDeviceRequirements&) = default; + +private: + friend class LegacyNormSpecAttachmentProfile; + + constexpr MaterialAttachmentDeviceRequirements() noexcept = default; + + // A future logical-device owner must enable every required feature before + // creating a device used with this profile. + bool mIndependentBlendRequired = true; +}; + +struct MaterialColorAttachmentProfile +{ + LLRenderContract::PixelFormat mLogicalFormat = LLRenderContract::PixelFormat::RGBA8Unorm; + VkFormat mNativeFormat = VK_FORMAT_UNDEFINED; + VkImageUsageFlags mUsage = 0; + VkFormatFeatureFlags mRequiredFeatures = 0; + VkColorComponentFlags mWriteMask = 0; + VkAttachmentLoadOp mRequiredLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + MaterialAttachmentAlphaSemantic mAlphaSemantic = MaterialAttachmentAlphaSemantic::Stored; + std::array mClearColor{}; + VkImageFormatProperties mCapabilities{}; +}; + +struct MaterialDepthAttachmentProfile +{ + LLRenderContract::PixelFormat mLogicalFormat = LLRenderContract::PixelFormat::Depth24Unorm; + VkFormat mNativeFormat = VK_FORMAT_UNDEFINED; + VkImageUsageFlags mUsage = 0; + VkFormatFeatureFlags mRequiredFeatures = 0; + VkAttachmentLoadOp mRequiredLoadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + float mClearDepth = 1.f; + std::uint32_t mClearStencil = 0; + VkImageFormatProperties mCapabilities{}; +}; + +// Only the resolver can construct or mutate this profile. The capability +// records are format/usage maxima, not a concrete allocation guarantee; a +// later image and framebuffer owner must validate its requested extent and +// resource size before publication. The RGB alpha-one semantic requires the +// recorded clear load operation, clear value, and write mask to be consumed +// together by the later render-pass and pipeline owners. deviceRequirements() +// returns a copy so callers cannot mutate the retained requirement. +class LegacyNormSpecAttachmentProfile +{ +public: + LegacyNormSpecAttachmentProfile(const LegacyNormSpecAttachmentProfile&) = default; + LegacyNormSpecAttachmentProfile& operator=(const LegacyNormSpecAttachmentProfile&) = default; + LegacyNormSpecAttachmentProfile(LegacyNormSpecAttachmentProfile&&) = default; + LegacyNormSpecAttachmentProfile& operator=(LegacyNormSpecAttachmentProfile&&) = default; + + LLRenderContract::LegacyNormSpecTargetProfile targetProfile() const noexcept { return mTargetProfile; } + MaterialAttachmentDeviceRequirements deviceRequirements() const noexcept { return mDeviceRequirements; } + const std::array& colors() const noexcept { return mColors; } + const MaterialDepthAttachmentProfile& depth() const noexcept { return mDepth; } + bool selectedFor(VkPhysicalDevice physical_device) const noexcept { return mPhysicalDevice == physical_device; } + +private: + friend struct MaterialAttachmentProfileFactory; + + LegacyNormSpecAttachmentProfile(LLRenderContract::LegacyNormSpecTargetProfile target_profile, VkPhysicalDevice physical_device) noexcept + : + mTargetProfile(target_profile), + mPhysicalDevice(physical_device) + { + } + + LLRenderContract::LegacyNormSpecTargetProfile mTargetProfile = LLRenderContract::LegacyNormSpecTargetProfile::ModernHDR; + VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE; + MaterialAttachmentDeviceRequirements mDeviceRequirements; + std::array mColors{}; + MaterialDepthAttachmentProfile mDepth; +}; + +using MaterialAttachmentResolutionResult = std::variant; + +MaterialAttachmentResolutionResult resolveLegacyNormSpecAttachmentProfile( + const MaterialAttachmentDevice& device, + const LLRenderContract::LegacyNormSpecPipelineKey& pipeline_key) noexcept; + +} // namespace LLRenderVulkanMaterial + +#endif // LL_LLRENDERVULKANMATERIALATTACHMENT_H diff --git a/indra/llrender/vulkan/llrendervulkanmaterialcapability.cpp b/indra/llrender/vulkan/llrendervulkanmaterialcapability.cpp new file mode 100644 index 00000000000..11b82a3b72b --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialcapability.cpp @@ -0,0 +1,340 @@ +/** + * @file llrendervulkanmaterialcapability.cpp + * @brief Physical-device capability closure for the canonical Vulkan material pipeline. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkanmaterialcapability.h" + +#include +#include +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ +static_assert(std::is_nothrow_copy_constructible_v); + +struct MaterialPipelineCapabilityProfileFactory +{ + static LegacyNormSpecPipelineCapabilityProfile create( + VkPhysicalDevice physical_device, + const LegacyNormSpecAttachmentProfile& attachment_profile, + const std::array& vertex_inputs, + bool portability_subset_advertised, + std::uint32_t api_version, + std::uint32_t min_vertex_input_binding_stride_alignment) noexcept + { + return LegacyNormSpecPipelineCapabilityProfile(physical_device, attachment_profile, vertex_inputs, portability_subset_advertised, + api_version, min_vertex_input_binding_stride_alignment); + } +}; + +namespace +{ + using namespace LLRenderContract; + + inline constexpr char PORTABILITY_SUBSET_EXTENSION[] = "VK_KHR_portability_subset"; + inline constexpr std::uint32_t ENUMERATION_ATTEMPTS = 3; + inline constexpr std::array CANONICAL_VERTEX_INPUTS{ + MaterialVertexInputCapability{ VertexSemantic::Position, VertexFormat::Float3, 0, 0, 0, 16, VK_FORMAT_R32G32B32_SFLOAT }, + MaterialVertexInputCapability{ VertexSemantic::Normal, VertexFormat::Float3, 1, 1, 0, 16, VK_FORMAT_R32G32B32_SFLOAT }, + MaterialVertexInputCapability{ VertexSemantic::TexCoord0, VertexFormat::Float2, 2, 2, 0, 8, VK_FORMAT_R32G32_SFLOAT }, + MaterialVertexInputCapability{ VertexSemantic::Color, VertexFormat::UNorm8x4, 3, 3, 0, 4, VK_FORMAT_R8G8B8A8_UNORM }, + MaterialVertexInputCapability{ VertexSemantic::Tangent, VertexFormat::Float4, 4, 4, 0, 16, VK_FORMAT_R32G32B32A32_SFLOAT }, + MaterialVertexInputCapability{ VertexSemantic::TexCoord1, VertexFormat::Float2, 5, 5, 0, 8, VK_FORMAT_R32G32_SFLOAT }, + MaterialVertexInputCapability{ VertexSemantic::TexCoord2, VertexFormat::Float2, 6, 6, 0, 8, VK_FORMAT_R32G32_SFLOAT } + }; + + MaterialPipelineCapabilityResolutionError failure(MaterialPipelineCapabilityResolutionCode code) noexcept + { + MaterialPipelineCapabilityResolutionError error; + error.mCode = code; + return error; + } + + MaterialPipelineCapabilityResolutionError queryFailure(MaterialPipelineCapabilityResolutionCode code, + MaterialPipelineCapabilityQuery query, + VkResult result = VK_SUCCESS) noexcept + { + auto error = failure(code); + error.mQuery = query; + error.mResult = result; + return error; + } + + template + std::optional allocationSizeFailure(std::uint32_t count, + MaterialPipelineCapabilityQuery query) noexcept + { + constexpr std::size_t MAX_COUNT = std::numeric_limits::max() / sizeof(Value); + if (static_cast(count) <= static_cast(MAX_COUNT)) + { + return std::nullopt; + } + + auto error = queryFailure(MaterialPipelineCapabilityResolutionCode::EnumerationCountExceeded, query); + error.mRequiredValue = MAX_COUNT; + error.mAvailableValue = count; + return error; + } + + std::optional requireGraphicsQueue(const MaterialPipelineCapabilityDevice& device) noexcept + { + std::uint32_t count = 0; + device.mDispatch.mGetPhysicalDeviceQueueFamilyProperties(device.mPhysicalDevice, &count, nullptr); + if (count == 0) + { + return queryFailure(MaterialPipelineCapabilityResolutionCode::MissingGraphicsQueueFamily, + MaterialPipelineCapabilityQuery::QueueFamilyProperties); + } + if (auto error = allocationSizeFailure(count, MaterialPipelineCapabilityQuery::QueueFamilyProperties)) + { + return error; + } + + std::unique_ptr properties(new (std::nothrow) VkQueueFamilyProperties[count]); + if (!properties) + { + return queryFailure(MaterialPipelineCapabilityResolutionCode::ScratchAllocationFailure, + MaterialPipelineCapabilityQuery::QueueFamilyProperties); + } + + const std::uint32_t capacity = count; + device.mDispatch.mGetPhysicalDeviceQueueFamilyProperties(device.mPhysicalDevice, &count, properties.get()); + if (count > capacity) + { + auto error = queryFailure(MaterialPipelineCapabilityResolutionCode::InvalidEnumerationOutput, + MaterialPipelineCapabilityQuery::QueueFamilyProperties); + error.mRequiredValue = capacity; + error.mAvailableValue = count; + return error; + } + + for (std::uint32_t index = 0; index < count; ++index) + { + if (properties[index].queueCount != 0 && (properties[index].queueFlags & VK_QUEUE_GRAPHICS_BIT) != 0) + { + return std::nullopt; + } + } + return queryFailure(MaterialPipelineCapabilityResolutionCode::MissingGraphicsQueueFamily, + MaterialPipelineCapabilityQuery::QueueFamilyProperties); + } + + struct DeviceExtensionResolution + { + bool mPortabilitySubsetAdvertised = false; + MaterialPipelineCapabilityResolutionError mError; + bool mSucceeded = false; + }; + + DeviceExtensionResolution resolveDeviceExtensions(const MaterialPipelineCapabilityDevice& device) noexcept + { + VkResult last_result = VK_INCOMPLETE; + for (std::uint32_t attempt = 1; attempt <= ENUMERATION_ATTEMPTS; ++attempt) + { + std::uint32_t count = 0; + const VkResult count_result = + device.mDispatch.mEnumerateDeviceExtensionProperties(device.mPhysicalDevice, nullptr, &count, nullptr); + if (count_result == VK_INCOMPLETE) + { + last_result = count_result; + continue; + } + if (count_result != VK_SUCCESS) + { + DeviceExtensionResolution resolution; + resolution.mError = queryFailure(MaterialPipelineCapabilityResolutionCode::EnumerationFailure, + MaterialPipelineCapabilityQuery::DeviceExtensionProperties, count_result); + resolution.mError.mEnumerationAttempt = attempt; + return resolution; + } + if (auto error = + allocationSizeFailure(count, MaterialPipelineCapabilityQuery::DeviceExtensionProperties)) + { + DeviceExtensionResolution resolution; + resolution.mError = *error; + resolution.mError.mEnumerationAttempt = attempt; + return resolution; + } + if (count == 0) + { + DeviceExtensionResolution resolution; + resolution.mSucceeded = true; + return resolution; + } + + std::unique_ptr properties(new (std::nothrow) VkExtensionProperties[count]); + if (!properties) + { + DeviceExtensionResolution resolution; + resolution.mError = queryFailure(MaterialPipelineCapabilityResolutionCode::ScratchAllocationFailure, + MaterialPipelineCapabilityQuery::DeviceExtensionProperties); + resolution.mError.mAvailableValue = count; + resolution.mError.mEnumerationAttempt = attempt; + return resolution; + } + + const std::uint32_t capacity = count; + const VkResult list_result = + device.mDispatch.mEnumerateDeviceExtensionProperties(device.mPhysicalDevice, nullptr, &count, properties.get()); + if (list_result != VK_SUCCESS && list_result != VK_INCOMPLETE) + { + DeviceExtensionResolution resolution; + resolution.mError = queryFailure(MaterialPipelineCapabilityResolutionCode::EnumerationFailure, + MaterialPipelineCapabilityQuery::DeviceExtensionProperties, list_result); + resolution.mError.mEnumerationAttempt = attempt; + return resolution; + } + if (count > capacity) + { + DeviceExtensionResolution resolution; + resolution.mError = queryFailure(MaterialPipelineCapabilityResolutionCode::InvalidEnumerationOutput, + MaterialPipelineCapabilityQuery::DeviceExtensionProperties, list_result); + resolution.mError.mRequiredValue = capacity; + resolution.mError.mAvailableValue = count; + resolution.mError.mEnumerationAttempt = attempt; + return resolution; + } + if (list_result == VK_INCOMPLETE) + { + last_result = list_result; + continue; + } + + DeviceExtensionResolution resolution; + for (std::uint32_t index = 0; index < count; ++index) + { + if (std::strncmp(properties[index].extensionName, PORTABILITY_SUBSET_EXTENSION, sizeof(PORTABILITY_SUBSET_EXTENSION)) == 0) + { + resolution.mPortabilitySubsetAdvertised = true; + break; + } + } + resolution.mSucceeded = true; + return resolution; + } + + DeviceExtensionResolution resolution; + resolution.mError = queryFailure(MaterialPipelineCapabilityResolutionCode::EnumerationIncomplete, + MaterialPipelineCapabilityQuery::DeviceExtensionProperties, last_result); + resolution.mError.mEnumerationAttempt = ENUMERATION_ATTEMPTS; + return resolution; + } + + std::optional resolvePortabilityAlignment( + const MaterialPipelineCapabilityDevice& device, + const std::array& vertex_inputs, + bool portability_subset_advertised, + std::uint32_t& alignment) noexcept + { + alignment = 1; + if (!portability_subset_advertised) + { + return std::nullopt; + } + + VkPhysicalDevicePortabilitySubsetPropertiesKHR portability_properties{}; + portability_properties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PORTABILITY_SUBSET_PROPERTIES_KHR; + + VkPhysicalDeviceProperties2 properties{}; + properties.sType = VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2; + properties.pNext = &portability_properties; + device.mDispatch.mGetPhysicalDeviceProperties2(device.mPhysicalDevice, &properties); + + alignment = portability_properties.minVertexInputBindingStrideAlignment; + if (alignment == 0 || (alignment & (alignment - 1)) != 0) + { + auto error = queryFailure(MaterialPipelineCapabilityResolutionCode::InvalidPortabilityAlignment, + MaterialPipelineCapabilityQuery::PhysicalDeviceProperties2); + error.mRequiredValue = 1; + error.mAvailableValue = alignment; + return error; + } + + for (const auto& input : vertex_inputs) + { + if (input.mStride < alignment || input.mStride % alignment != 0) + { + auto error = queryFailure(MaterialPipelineCapabilityResolutionCode::IncompatiblePortabilityStride, + MaterialPipelineCapabilityQuery::PhysicalDeviceProperties2); + error.mVertexBinding = input.mBinding; + error.mRequiredValue = alignment; + error.mAvailableValue = input.mStride; + return error; + } + } + return std::nullopt; + } + +} // namespace + +MaterialPipelineCapabilityResolutionResult resolveLegacyNormSpecPipelineCapabilityProfile( + const MaterialPipelineCapabilityDevice& device, + const LegacyNormSpecAttachmentProfile& attachment_profile) noexcept +{ + if (device.mPhysicalDevice == VK_NULL_HANDLE) + { + return failure(MaterialPipelineCapabilityResolutionCode::InvalidPhysicalDevice); + } + if (!device.mDispatch.mGetPhysicalDeviceProperties || !device.mDispatch.mGetPhysicalDeviceQueueFamilyProperties || + !device.mDispatch.mEnumerateDeviceExtensionProperties || !device.mDispatch.mGetPhysicalDeviceProperties2) + { + return failure(MaterialPipelineCapabilityResolutionCode::InvalidDispatch); + } + if (!attachment_profile.selectedFor(device.mPhysicalDevice)) + { + return failure(MaterialPipelineCapabilityResolutionCode::AttachmentProfilePhysicalDeviceMismatch); + } + VkPhysicalDeviceProperties properties{}; + device.mDispatch.mGetPhysicalDeviceProperties(device.mPhysicalDevice, &properties); + if (VK_API_VERSION_VARIANT(properties.apiVersion) != 0) + { + auto error = queryFailure(MaterialPipelineCapabilityResolutionCode::UnsupportedApiVariant, + MaterialPipelineCapabilityQuery::PhysicalDeviceProperties); + error.mRequiredValue = 0; + error.mAvailableValue = VK_API_VERSION_VARIANT(properties.apiVersion); + return error; + } + if (properties.apiVersion < VK_API_VERSION_1_1) + { + auto error = queryFailure(MaterialPipelineCapabilityResolutionCode::InsufficientApiVersion, + MaterialPipelineCapabilityQuery::PhysicalDeviceProperties); + error.mRequiredValue = VK_API_VERSION_1_1; + error.mAvailableValue = properties.apiVersion; + return error; + } + auto vertex_inputs = CANONICAL_VERTEX_INPUTS; + if (auto error = requireGraphicsQueue(device)) + { + return *error; + } + + const DeviceExtensionResolution extensions = resolveDeviceExtensions(device); + if (!extensions.mSucceeded) + { + return extensions.mError; + } + + std::uint32_t alignment = 1; + if (auto error = resolvePortabilityAlignment(device, vertex_inputs, extensions.mPortabilitySubsetAdvertised, alignment)) + { + return *error; + } + + return MaterialPipelineCapabilityProfileFactory::create(device.mPhysicalDevice, attachment_profile, vertex_inputs, + extensions.mPortabilitySubsetAdvertised, properties.apiVersion, alignment); +} + +} // namespace LLRenderVulkanMaterial diff --git a/indra/llrender/vulkan/llrendervulkanmaterialcapability.h b/indra/llrender/vulkan/llrendervulkanmaterialcapability.h new file mode 100644 index 00000000000..30178949fdc --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialcapability.h @@ -0,0 +1,194 @@ +/** + * @file llrendervulkanmaterialcapability.h + * @brief Physical-device capability closure for the canonical Vulkan material pipeline. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANMATERIALCAPABILITY_H +#define LL_LLRENDERVULKANMATERIALCAPABILITY_H + +#include "llrendervulkanmaterialattachment.h" + +#include + +#include +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ + +inline constexpr std::uint32_t LEGACY_NORMSPEC_VERTEX_INPUT_COUNT = 7; + +struct MaterialPipelineCapabilityDispatch +{ + PFN_vkGetPhysicalDeviceProperties mGetPhysicalDeviceProperties = nullptr; + PFN_vkGetPhysicalDeviceQueueFamilyProperties mGetPhysicalDeviceQueueFamilyProperties = nullptr; + PFN_vkEnumerateDeviceExtensionProperties mEnumerateDeviceExtensionProperties = nullptr; + PFN_vkGetPhysicalDeviceProperties2 mGetPhysicalDeviceProperties2 = nullptr; +}; + +// The physical device and the implementation addressed by these callbacks +// remain owned by the caller. This service performs queries only and creates no +// native object. +struct MaterialPipelineCapabilityDevice +{ + VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE; + MaterialPipelineCapabilityDispatch mDispatch; +}; + +enum class MaterialPipelineCapabilityQuery : std::uint8_t +{ + PhysicalDeviceProperties, + QueueFamilyProperties, + DeviceExtensionProperties, + PhysicalDeviceProperties2 +}; + +enum class MaterialPipelineCapabilityResolutionCode : std::uint8_t +{ + InvalidPhysicalDevice, + InvalidDispatch, + AttachmentProfilePhysicalDeviceMismatch, + UnsupportedApiVariant, + InsufficientApiVersion, + MissingGraphicsQueueFamily, + EnumerationFailure, + EnumerationIncomplete, + EnumerationCountExceeded, + InvalidEnumerationOutput, + ScratchAllocationFailure, + InvalidPortabilityAlignment, + IncompatiblePortabilityStride +}; + +struct MaterialPipelineCapabilityResolutionError +{ + MaterialPipelineCapabilityResolutionCode mCode = MaterialPipelineCapabilityResolutionCode::InvalidPhysicalDevice; + std::optional mQuery; + std::optional mVertexBinding; + std::uint64_t mRequiredValue = 0; + std::uint64_t mAvailableValue = 0; + std::uint32_t mEnumerationAttempt = 0; + VkResult mResult = VK_SUCCESS; + + friend constexpr bool operator==(const MaterialPipelineCapabilityResolutionError&, + const MaterialPipelineCapabilityResolutionError&) = default; +}; + +struct MaterialVertexInputCapability +{ + LLRenderContract::VertexSemantic mSemantic = LLRenderContract::VertexSemantic::Position; + LLRenderContract::VertexFormat mLogicalFormat = LLRenderContract::VertexFormat::Float3; + std::uint32_t mLocation = 0; + std::uint32_t mBinding = 0; + std::uint32_t mOffset = 0; + std::uint32_t mStride = 0; + VkFormat mNativeFormat = VK_FORMAT_UNDEFINED; + + friend constexpr bool operator==(const MaterialVertexInputCapability&, const MaterialVertexInputCapability&) = default; +}; + +// These are obligations for a future logical-device transaction, not evidence +// that an arbitrary VkDevice enabled them. The future owner of the exact +// VkDeviceCreateInfo must authenticate feature, extension, and queue creation. +class MaterialPipelineLogicalDeviceRequirements +{ +public: + MaterialPipelineLogicalDeviceRequirements(const MaterialPipelineLogicalDeviceRequirements&) = default; + MaterialPipelineLogicalDeviceRequirements& operator=(const MaterialPipelineLogicalDeviceRequirements&) = default; + MaterialPipelineLogicalDeviceRequirements(MaterialPipelineLogicalDeviceRequirements&&) = default; + MaterialPipelineLogicalDeviceRequirements& operator=(MaterialPipelineLogicalDeviceRequirements&&) = default; + + bool independentBlendRequired() const noexcept { return mIndependentBlendRequired; } + bool graphicsQueueRequired() const noexcept { return mGraphicsQueueRequired; } + bool portabilitySubsetExtensionRequired() const noexcept { return mPortabilitySubsetExtensionRequired; } + + friend constexpr bool operator==(const MaterialPipelineLogicalDeviceRequirements&, + const MaterialPipelineLogicalDeviceRequirements&) = default; + +private: + friend class LegacyNormSpecPipelineCapabilityProfile; + + constexpr MaterialPipelineLogicalDeviceRequirements(bool independent_blend_required, + bool portability_subset_extension_required) noexcept : + mIndependentBlendRequired(independent_blend_required), + mPortabilitySubsetExtensionRequired(portability_subset_extension_required) + { + } + + bool mIndependentBlendRequired = true; + bool mGraphicsQueueRequired = true; + bool mPortabilitySubsetExtensionRequired = false; +}; + +// Only the resolver can construct this physical-device-specific value. It +// retains the exact attachment profile with which the remaining production +// pipeline requirements were proven. No VkDevice enablement is inferred. +class LegacyNormSpecPipelineCapabilityProfile +{ +public: + LegacyNormSpecPipelineCapabilityProfile(const LegacyNormSpecPipelineCapabilityProfile&) = default; + LegacyNormSpecPipelineCapabilityProfile& operator=(const LegacyNormSpecPipelineCapabilityProfile&) = default; + LegacyNormSpecPipelineCapabilityProfile(LegacyNormSpecPipelineCapabilityProfile&&) = default; + LegacyNormSpecPipelineCapabilityProfile& operator=(LegacyNormSpecPipelineCapabilityProfile&&) = default; + + const LegacyNormSpecAttachmentProfile& attachmentProfile() const noexcept { return mAttachmentProfile; } + const std::array& vertexInputs() const noexcept + { + return mVertexInputs; + } + MaterialPipelineLogicalDeviceRequirements logicalDeviceRequirements() const noexcept { return mLogicalDeviceRequirements; } + LLRenderContract::LegacyNormSpecTargetProfile targetProfile() const noexcept { return mAttachmentProfile.targetProfile(); } + bool selectedFor(VkPhysicalDevice physical_device) const noexcept { return mPhysicalDevice == physical_device; } + bool portabilitySubsetAdvertised() const noexcept { return mLogicalDeviceRequirements.portabilitySubsetExtensionRequired(); } + std::uint32_t apiVersion() const noexcept { return mApiVersion; } + std::uint32_t minVertexInputBindingStrideAlignment() const noexcept { return mMinVertexInputBindingStrideAlignment; } + +private: + friend struct MaterialPipelineCapabilityProfileFactory; + + LegacyNormSpecPipelineCapabilityProfile( + VkPhysicalDevice physical_device, + const LegacyNormSpecAttachmentProfile& attachment_profile, + const std::array& vertex_inputs, + bool portability_subset_advertised, + std::uint32_t api_version, + std::uint32_t min_vertex_input_binding_stride_alignment) noexcept : + mPhysicalDevice(physical_device), + mAttachmentProfile(attachment_profile), + mVertexInputs(vertex_inputs), + mLogicalDeviceRequirements(attachment_profile.deviceRequirements().independentBlendRequired(), portability_subset_advertised), + mApiVersion(api_version), + mMinVertexInputBindingStrideAlignment(min_vertex_input_binding_stride_alignment) + { + } + + VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE; + LegacyNormSpecAttachmentProfile mAttachmentProfile; + std::array mVertexInputs{}; + MaterialPipelineLogicalDeviceRequirements mLogicalDeviceRequirements; + std::uint32_t mApiVersion = 0; + std::uint32_t mMinVertexInputBindingStrideAlignment = 1; +}; + +using MaterialPipelineCapabilityResolutionResult = + std::variant; + +MaterialPipelineCapabilityResolutionResult resolveLegacyNormSpecPipelineCapabilityProfile( + const MaterialPipelineCapabilityDevice& device, + const LegacyNormSpecAttachmentProfile& attachment_profile) noexcept; + +} // namespace LLRenderVulkanMaterial + +#endif // LL_LLRENDERVULKANMATERIALCAPABILITY_H diff --git a/indra/llrender/vulkan/llrendervulkanmaterialdescriptor.cpp b/indra/llrender/vulkan/llrendervulkanmaterialdescriptor.cpp new file mode 100644 index 00000000000..3b33692bebf --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialdescriptor.cpp @@ -0,0 +1,275 @@ +/** + * @file llrendervulkanmaterialdescriptor.cpp + * @brief Immutable populated Vulkan descriptors for the canonical material interface. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkanmaterialdescriptor.h" + +#include "llmaterialcontract.h" + +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ +namespace +{ + + MaterialDescriptorCreationError failure(MaterialDescriptorCreationCode code, + std::optional tuple_index = std::nullopt, + std::optional sampled_image_index = std::nullopt, + VkResult result = VK_SUCCESS) noexcept + { + return { code, tuple_index, sampled_image_index, result }; + } + + bool validUniformRange(const MaterialUniformDescriptorResource& uniform) noexcept + { + constexpr VkDeviceSize PARAMETER_SIZE = static_cast(sizeof(LLRenderContract::MaterialParameters)); + return uniform.mOffset <= uniform.mSize && uniform.mSize - uniform.mOffset >= PARAMETER_SIZE; + } + +} // namespace + +struct MaterialDescriptorFactory +{ + static std::unique_ptr allocate(const MaterialDescriptorDevice& device, + std::vector&& bindings) noexcept + { + return std::unique_ptr(new (std::nothrow) + LegacyNormSpecDescriptorGeneration(device, std::move(bindings))); + } + + static VkDescriptorPool& pool(LegacyNormSpecDescriptorGeneration& generation) noexcept { return generation.mPool; } + + static MaterialDescriptorBinding& binding(LegacyNormSpecDescriptorGeneration& generation, std::size_t index) noexcept + { + return generation.mBindings[index]; + } +}; + +LegacyNormSpecDescriptorGeneration::LegacyNormSpecDescriptorGeneration(const MaterialDescriptorDevice& device, + std::vector&& bindings) noexcept : + mDevice(device.mDevice), + mDestroyDescriptorPool(device.mDispatch.mDestroyDescriptorPool), + mBindings(std::move(bindings)) +{ +} + +LegacyNormSpecDescriptorGeneration::~LegacyNormSpecDescriptorGeneration() noexcept +{ + if (mPool != VK_NULL_HANDLE) + { + mDestroyDescriptorPool(mDevice, mPool, nullptr); + } +} + +std::optional LegacyNormSpecDescriptorGeneration::binding(std::size_t index) const noexcept +{ + if (index >= mBindings.size()) + { + return std::nullopt; + } + return mBindings[index]; +} + +bool validMaterialDescriptorGenerationCount(std::size_t count) noexcept +{ + constexpr std::size_t MAX_COUNT = static_cast(std::numeric_limits::max()) / 4U; + return count != 0 && count <= MAX_COUNT; +} + +MaterialDescriptorCreationResult createLegacyNormSpecDescriptorGeneration( + const MaterialDescriptorDevice& device, + const LegacyNormSpecPipelineLayout& layout, + const std::vector& resources) noexcept +{ + if (device.mDevice == VK_NULL_HANDLE) + { + return failure(MaterialDescriptorCreationCode::InvalidDevice); + } + if (!device.mDispatch.mCreateDescriptorPool || !device.mDispatch.mDestroyDescriptorPool || !device.mDispatch.mAllocateDescriptorSets || + !device.mDispatch.mUpdateDescriptorSets) + { + return failure(MaterialDescriptorCreationCode::InvalidDispatch); + } + if (!layout.createdOn(device.mDevice)) + { + return failure(MaterialDescriptorCreationCode::LayoutDeviceMismatch); + } + if (resources.empty()) + { + return failure(MaterialDescriptorCreationCode::EmptyBatch); + } + if (!validMaterialDescriptorGenerationCount(resources.size())) + { + return failure(MaterialDescriptorCreationCode::BatchTooLarge); + } + + for (std::size_t tuple_index = 0; tuple_index < resources.size(); ++tuple_index) + { + const MaterialDescriptorResources& tuple = resources[tuple_index]; + if (tuple.mParameters.mBuffer == VK_NULL_HANDLE) + { + return failure(MaterialDescriptorCreationCode::InvalidUniformBuffer, tuple_index); + } + if (!validUniformRange(tuple.mParameters)) + { + return failure(MaterialDescriptorCreationCode::InvalidUniformRange, tuple_index); + } + for (std::uint32_t image_index = 0; image_index < tuple.mSampledImages.size(); ++image_index) + { + const MaterialSampledDescriptorResource& sampled = tuple.mSampledImages[image_index]; + if (sampled.mView == VK_NULL_HANDLE) + { + return failure(MaterialDescriptorCreationCode::InvalidSampledImage, tuple_index, image_index); + } + if (sampled.mSampler == VK_NULL_HANDLE) + { + return failure(MaterialDescriptorCreationCode::InvalidSampler, tuple_index, image_index); + } + } + } + + const std::uint32_t tuple_count = static_cast(resources.size()); + const std::uint32_t set_count = tuple_count * 2U; + const std::uint32_t image_count = tuple_count * 3U; + const std::uint32_t write_count = tuple_count * 4U; + + std::vector bindings; + std::vector set_layouts; + std::vector descriptor_sets; + std::vector buffer_infos; + std::vector image_infos; + std::vector writes; + try + { + bindings.resize(resources.size()); + set_layouts.resize(set_count); + descriptor_sets.resize(set_count, VK_NULL_HANDLE); + buffer_infos.resize(tuple_count); + image_infos.resize(image_count); + writes.resize(write_count); + } + catch (...) + { + return failure(MaterialDescriptorCreationCode::OwnerAllocationFailure); + } + + const std::array canonical_layouts = layout.descriptorSetLayouts(); + for (std::size_t tuple_index = 0; tuple_index < resources.size(); ++tuple_index) + { + bindings[tuple_index].mResources = resources[tuple_index]; + set_layouts[tuple_index * 2U] = canonical_layouts[0]; + set_layouts[tuple_index * 2U + 1U] = canonical_layouts[1]; + + const MaterialUniformDescriptorResource& uniform = resources[tuple_index].mParameters; + VkDescriptorBufferInfo& buffer_info = buffer_infos[tuple_index]; + buffer_info.buffer = uniform.mBuffer; + buffer_info.offset = uniform.mOffset; + buffer_info.range = static_cast(sizeof(LLRenderContract::MaterialParameters)); + + VkWriteDescriptorSet& uniform_write = writes[tuple_index * 4U]; + uniform_write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + uniform_write.dstBinding = 0; + uniform_write.descriptorCount = 1; + uniform_write.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + uniform_write.pBufferInfo = &buffer_info; + + for (std::uint32_t image_index = 0; image_index < 3U; ++image_index) + { + const std::size_t image_offset = tuple_index * 3U + image_index; + const MaterialSampledDescriptorResource& sampled = resources[tuple_index].mSampledImages[image_index]; + VkDescriptorImageInfo& image_info = image_infos[image_offset]; + image_info.sampler = sampled.mSampler; + image_info.imageView = sampled.mView; + image_info.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + + VkWriteDescriptorSet& image_write = writes[tuple_index * 4U + image_index + 1U]; + image_write.sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + image_write.dstBinding = image_index; + image_write.descriptorCount = 1; + image_write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + image_write.pImageInfo = &image_info; + } + } + + std::unique_ptr generation = MaterialDescriptorFactory::allocate(device, std::move(bindings)); + if (!generation) + { + return failure(MaterialDescriptorCreationCode::OwnerAllocationFailure); + } + + const std::array pool_sizes{ VkDescriptorPoolSize{ VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, tuple_count }, + VkDescriptorPoolSize{ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, image_count } }; + VkDescriptorPoolCreateInfo pool_info{}; + pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + pool_info.maxSets = set_count; + pool_info.poolSizeCount = static_cast(pool_sizes.size()); + pool_info.pPoolSizes = pool_sizes.data(); + + VkDescriptorPool pool = VK_NULL_HANDLE; + const VkResult pool_result = device.mDispatch.mCreateDescriptorPool(device.mDevice, &pool_info, nullptr, &pool); + if (pool_result != VK_SUCCESS) + { + return failure(MaterialDescriptorCreationCode::PoolCreateFailure, std::nullopt, std::nullopt, pool_result); + } + if (pool == VK_NULL_HANDLE) + { + return failure(MaterialDescriptorCreationCode::NullPool); + } + MaterialDescriptorFactory::pool(*generation) = pool; + + VkDescriptorSetAllocateInfo allocate_info{}; + allocate_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocate_info.descriptorPool = pool; + allocate_info.descriptorSetCount = set_count; + allocate_info.pSetLayouts = set_layouts.data(); + const VkResult allocation_result = device.mDispatch.mAllocateDescriptorSets(device.mDevice, &allocate_info, descriptor_sets.data()); + if (allocation_result != VK_SUCCESS) + { + return failure(MaterialDescriptorCreationCode::SetAllocationFailure, std::nullopt, std::nullopt, allocation_result); + } + + for (std::size_t set_index = 0; set_index < descriptor_sets.size(); ++set_index) + { + if (descriptor_sets[set_index] != VK_NULL_HANDLE) + { + continue; + } + const std::size_t tuple_index = set_index / 2U; + return failure(set_index % 2U == 0U ? MaterialDescriptorCreationCode::NullParameterSet + : MaterialDescriptorCreationCode::NullSampledImageSet, + tuple_index); + } + + for (std::size_t tuple_index = 0; tuple_index < resources.size(); ++tuple_index) + { + MaterialDescriptorBinding& binding = MaterialDescriptorFactory::binding(*generation, tuple_index); + binding.mSets.mParameters = descriptor_sets[tuple_index * 2U]; + binding.mSets.mSampledImages = descriptor_sets[tuple_index * 2U + 1U]; + + VkWriteDescriptorSet& uniform_write = writes[tuple_index * 4U]; + uniform_write.dstSet = binding.mSets.mParameters; + for (std::uint32_t image_index = 0; image_index < 3U; ++image_index) + { + writes[tuple_index * 4U + image_index + 1U].dstSet = binding.mSets.mSampledImages; + } + } + + device.mDispatch.mUpdateDescriptorSets(device.mDevice, write_count, writes.data(), 0, nullptr); + return generation; +} + +} // namespace LLRenderVulkanMaterial diff --git a/indra/llrender/vulkan/llrendervulkanmaterialdescriptor.h b/indra/llrender/vulkan/llrendervulkanmaterialdescriptor.h new file mode 100644 index 00000000000..64ab93d5ab1 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialdescriptor.h @@ -0,0 +1,166 @@ +/** + * @file llrendervulkanmaterialdescriptor.h + * @brief Immutable populated Vulkan descriptors for the canonical material interface. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANMATERIALDESCRIPTOR_H +#define LL_LLRENDERVULKANMATERIALDESCRIPTOR_H + +#include "llrendervulkanmateriallayout.h" + +#include + +#include +#include +#include +#include +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ + +struct MaterialDescriptorDispatch +{ + PFN_vkCreateDescriptorPool mCreateDescriptorPool = nullptr; + PFN_vkDestroyDescriptorPool mDestroyDescriptorPool = nullptr; + PFN_vkAllocateDescriptorSets mAllocateDescriptorSets = nullptr; + PFN_vkUpdateDescriptorSets mUpdateDescriptorSets = nullptr; +}; + +// The Vulkan 1.1-or-newer device, its implementation dispatch, and external +// host synchronization remain the caller's responsibility for the lifetime +// of every generation. +struct MaterialDescriptorDevice +{ + VkDevice mDevice = VK_NULL_HANDLE; + MaterialDescriptorDispatch mDispatch; +}; + +struct MaterialUniformDescriptorResource +{ + VkBuffer mBuffer = VK_NULL_HANDLE; + VkDeviceSize mSize = 0; + VkDeviceSize mOffset = 0; + + friend constexpr bool operator==(const MaterialUniformDescriptorResource&, const MaterialUniformDescriptorResource&) = default; +}; + +struct MaterialSampledDescriptorResource +{ + VkSampler mSampler = VK_NULL_HANDLE; + VkImageView mView = VK_NULL_HANDLE; + + friend constexpr bool operator==(const MaterialSampledDescriptorResource&, const MaterialSampledDescriptorResource&) = default; +}; + +struct MaterialDescriptorResources +{ + MaterialUniformDescriptorResource mParameters; + std::array mSampledImages{}; + + friend constexpr bool operator==(const MaterialDescriptorResources&, const MaterialDescriptorResources&) = default; +}; + +struct MaterialDescriptorSetPair +{ + VkDescriptorSet mParameters = VK_NULL_HANDLE; + VkDescriptorSet mSampledImages = VK_NULL_HANDLE; + + friend constexpr bool operator==(const MaterialDescriptorSetPair&, const MaterialDescriptorSetPair&) = default; +}; + +struct MaterialDescriptorBinding +{ + MaterialDescriptorSetPair mSets; + MaterialDescriptorResources mResources; + + friend constexpr bool operator==(const MaterialDescriptorBinding&, const MaterialDescriptorBinding&) = default; +}; + +enum class MaterialDescriptorCreationCode : std::uint8_t +{ + InvalidDevice, + InvalidDispatch, + LayoutDeviceMismatch, + EmptyBatch, + BatchTooLarge, + InvalidUniformBuffer, + InvalidUniformRange, + InvalidSampledImage, + InvalidSampler, + OwnerAllocationFailure, + PoolCreateFailure, + NullPool, + SetAllocationFailure, + NullParameterSet, + NullSampledImageSet +}; + +struct MaterialDescriptorCreationError +{ + MaterialDescriptorCreationCode mCode = MaterialDescriptorCreationCode::InvalidDevice; + std::optional mTupleIndex; + std::optional mSampledImageIndex; + VkResult mResult = VK_SUCCESS; + + friend constexpr bool operator==(const MaterialDescriptorCreationError&, const MaterialDescriptorCreationError&) = default; +}; + +class LegacyNormSpecDescriptorGeneration +{ +public: + ~LegacyNormSpecDescriptorGeneration() noexcept; + + LegacyNormSpecDescriptorGeneration(const LegacyNormSpecDescriptorGeneration&) = delete; + LegacyNormSpecDescriptorGeneration& operator=(const LegacyNormSpecDescriptorGeneration&) = delete; + LegacyNormSpecDescriptorGeneration(LegacyNormSpecDescriptorGeneration&&) = delete; + LegacyNormSpecDescriptorGeneration& operator=(LegacyNormSpecDescriptorGeneration&&) = delete; + + std::size_t size() const noexcept { return mBindings.size(); } + + // Returned bindings contain borrowed handles and copied metadata. The sets + // expire with this generation; resources remain owned by the caller. + std::optional binding(std::size_t index) const noexcept; + +private: + friend struct MaterialDescriptorFactory; + + LegacyNormSpecDescriptorGeneration(const MaterialDescriptorDevice& device, std::vector&& bindings) noexcept; + + VkDevice mDevice = VK_NULL_HANDLE; + PFN_vkDestroyDescriptorPool mDestroyDescriptorPool = nullptr; + VkDescriptorPool mPool = VK_NULL_HANDLE; + std::vector mBindings; +}; + +using MaterialDescriptorCreationResult = std::variant>; + +bool validMaterialDescriptorGenerationCount(std::size_t count) noexcept; + +// The layout owner conservatively outlives the returned generation. mSize is +// the buffer's creation size, and mOffset must satisfy the creating device's +// minUniformBufferOffsetAlignment. Every buffer, view, and sampler must belong +// to device and match the declared use. Sampled images must be in shader-read- +// only layout when consumed and use no YCbCr conversion. All resources remain +// alive through the last submitted command that consumes the generation; +// destruction is legal only after those commands complete. +MaterialDescriptorCreationResult createLegacyNormSpecDescriptorGeneration( + const MaterialDescriptorDevice& device, + const LegacyNormSpecPipelineLayout& layout, + const std::vector& resources) noexcept; + +} // namespace LLRenderVulkanMaterial + +#endif // LL_LLRENDERVULKANMATERIALDESCRIPTOR_H diff --git a/indra/llrender/vulkan/llrendervulkanmateriallayout.cpp b/indra/llrender/vulkan/llrendervulkanmateriallayout.cpp new file mode 100644 index 00000000000..9963324c16c --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmateriallayout.cpp @@ -0,0 +1,179 @@ +/** + * @file llrendervulkanmateriallayout.cpp + * @brief Transactional Vulkan layouts for the canonical material interface. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkanmateriallayout.h" + +#include + +namespace LLRenderVulkanMaterial +{ +namespace +{ + + MaterialLayoutCreationError failure(MaterialLayoutCreationCode code, + std::optional object = std::nullopt, + VkResult result = VK_SUCCESS) noexcept + { + return { code, object, result }; + } + + VkDescriptorSetLayoutCreateInfo parameterSetCreateInfo(VkDescriptorSetLayoutBinding& binding) noexcept + { + binding.binding = 0; + binding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + binding.pImmutableSamplers = nullptr; + + VkDescriptorSetLayoutCreateInfo info{}; + info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + info.bindingCount = 1; + info.pBindings = &binding; + return info; + } + + VkDescriptorSetLayoutCreateInfo sampledImageSetCreateInfo(std::array& bindings) noexcept + { + for (std::uint32_t binding_index = 0; binding_index < bindings.size(); ++binding_index) + { + VkDescriptorSetLayoutBinding& binding = bindings[binding_index]; + binding.binding = binding_index; + binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + binding.pImmutableSamplers = nullptr; + } + + VkDescriptorSetLayoutCreateInfo info{}; + info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + info.bindingCount = static_cast(bindings.size()); + info.pBindings = bindings.data(); + return info; + } + + VkPipelineLayoutCreateInfo pipelineCreateInfo(const std::array& layouts) noexcept + { + VkPipelineLayoutCreateInfo info{}; + info.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + info.setLayoutCount = static_cast(layouts.size()); + info.pSetLayouts = layouts.data(); + return info; + } + +} // namespace + +struct MaterialLayoutFactory +{ + static std::unique_ptr allocate(const MaterialLayoutDevice& device) noexcept + { + return std::unique_ptr(new (std::nothrow) LegacyNormSpecPipelineLayout(device)); + } + + static VkDescriptorSetLayout& parameterSet(LegacyNormSpecPipelineLayout& layout) noexcept { return layout.mDescriptorSetLayouts[0]; } + + static VkDescriptorSetLayout& sampledImageSet(LegacyNormSpecPipelineLayout& layout) noexcept { return layout.mDescriptorSetLayouts[1]; } + + static VkPipelineLayout& pipeline(LegacyNormSpecPipelineLayout& layout) noexcept { return layout.mPipelineLayout; } +}; + +LegacyNormSpecPipelineLayout::LegacyNormSpecPipelineLayout(const MaterialLayoutDevice& device) noexcept : + mDevice(device.mDevice), + mDestroyDescriptorSetLayout(device.mDispatch.mDestroyDescriptorSetLayout), + mDestroyPipelineLayout(device.mDispatch.mDestroyPipelineLayout) +{ +} + +LegacyNormSpecPipelineLayout::~LegacyNormSpecPipelineLayout() noexcept +{ + if (mPipelineLayout != VK_NULL_HANDLE) + { + mDestroyPipelineLayout(mDevice, mPipelineLayout, nullptr); + } + if (mDescriptorSetLayouts[1] != VK_NULL_HANDLE) + { + mDestroyDescriptorSetLayout(mDevice, mDescriptorSetLayouts[1], nullptr); + } + if (mDescriptorSetLayouts[0] != VK_NULL_HANDLE) + { + mDestroyDescriptorSetLayout(mDevice, mDescriptorSetLayouts[0], nullptr); + } +} + +MaterialLayoutCreationResult createLegacyNormSpecPipelineLayout(const MaterialLayoutDevice& device) noexcept +{ + if (device.mDevice == VK_NULL_HANDLE) + { + return failure(MaterialLayoutCreationCode::InvalidDevice); + } + if (!device.mDispatch.mCreateDescriptorSetLayout || !device.mDispatch.mDestroyDescriptorSetLayout || + !device.mDispatch.mCreatePipelineLayout || !device.mDispatch.mDestroyPipelineLayout) + { + return failure(MaterialLayoutCreationCode::InvalidDispatch); + } + + VkDescriptorSetLayoutBinding parameter_binding{}; + std::array sampled_bindings{}; + const VkDescriptorSetLayoutCreateInfo parameter_info = parameterSetCreateInfo(parameter_binding); + const VkDescriptorSetLayoutCreateInfo sampled_info = sampledImageSetCreateInfo(sampled_bindings); + + std::unique_ptr layout = MaterialLayoutFactory::allocate(device); + if (!layout) + { + return failure(MaterialLayoutCreationCode::OwnerAllocationFailure); + } + + VkDescriptorSetLayout parameter_layout = VK_NULL_HANDLE; + const VkResult parameter_result = + device.mDispatch.mCreateDescriptorSetLayout(device.mDevice, ¶meter_info, nullptr, ¶meter_layout); + if (parameter_result != VK_SUCCESS) + { + return failure(MaterialLayoutCreationCode::CreateFailure, MaterialLayoutObject::ParameterSetLayout, parameter_result); + } + if (parameter_layout == VK_NULL_HANDLE) + { + return failure(MaterialLayoutCreationCode::NullHandle, MaterialLayoutObject::ParameterSetLayout); + } + MaterialLayoutFactory::parameterSet(*layout) = parameter_layout; + + VkDescriptorSetLayout sampled_layout = VK_NULL_HANDLE; + const VkResult sampled_result = device.mDispatch.mCreateDescriptorSetLayout(device.mDevice, &sampled_info, nullptr, &sampled_layout); + if (sampled_result != VK_SUCCESS) + { + return failure(MaterialLayoutCreationCode::CreateFailure, MaterialLayoutObject::SampledImageSetLayout, sampled_result); + } + if (sampled_layout == VK_NULL_HANDLE) + { + return failure(MaterialLayoutCreationCode::NullHandle, MaterialLayoutObject::SampledImageSetLayout); + } + MaterialLayoutFactory::sampledImageSet(*layout) = sampled_layout; + + const std::array set_layouts = layout->descriptorSetLayouts(); + const VkPipelineLayoutCreateInfo pipeline_info = pipelineCreateInfo(set_layouts); + VkPipelineLayout pipeline_layout = VK_NULL_HANDLE; + const VkResult pipeline_result = device.mDispatch.mCreatePipelineLayout(device.mDevice, &pipeline_info, nullptr, &pipeline_layout); + if (pipeline_result != VK_SUCCESS) + { + return failure(MaterialLayoutCreationCode::CreateFailure, MaterialLayoutObject::PipelineLayout, pipeline_result); + } + if (pipeline_layout == VK_NULL_HANDLE) + { + return failure(MaterialLayoutCreationCode::NullHandle, MaterialLayoutObject::PipelineLayout); + } + MaterialLayoutFactory::pipeline(*layout) = pipeline_layout; + + return layout; +} + +} // namespace LLRenderVulkanMaterial diff --git a/indra/llrender/vulkan/llrendervulkanmateriallayout.h b/indra/llrender/vulkan/llrendervulkanmateriallayout.h new file mode 100644 index 00000000000..29fdffa33cd --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmateriallayout.h @@ -0,0 +1,110 @@ +/** + * @file llrendervulkanmateriallayout.h + * @brief Transactional Vulkan layouts for the canonical material interface. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANMATERIALLAYOUT_H +#define LL_LLRENDERVULKANMATERIALLAYOUT_H + +#include + +#include +#include +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ + +struct MaterialLayoutDispatch +{ + PFN_vkCreateDescriptorSetLayout mCreateDescriptorSetLayout = nullptr; + PFN_vkDestroyDescriptorSetLayout mDestroyDescriptorSetLayout = nullptr; + PFN_vkCreatePipelineLayout mCreatePipelineLayout = nullptr; + PFN_vkDestroyPipelineLayout mDestroyPipelineLayout = nullptr; +}; + +// mDevice belongs to a Vulkan 1.1-or-newer logical device. The logical device +// and the implementation addressed by these function pointers must remain +// valid until every returned layout owner has been destroyed. Callers +// externally synchronize host access to the device and layouts. +struct MaterialLayoutDevice +{ + VkDevice mDevice = VK_NULL_HANDLE; + MaterialLayoutDispatch mDispatch; +}; + +enum class MaterialLayoutObject : std::uint8_t +{ + ParameterSetLayout, + SampledImageSetLayout, + PipelineLayout +}; + +enum class MaterialLayoutCreationCode : std::uint8_t +{ + InvalidDevice, + InvalidDispatch, + OwnerAllocationFailure, + CreateFailure, + NullHandle +}; + +struct MaterialLayoutCreationError +{ + MaterialLayoutCreationCode mCode = MaterialLayoutCreationCode::InvalidDevice; + std::optional mObject; + VkResult mResult = VK_SUCCESS; + + friend constexpr bool operator==(const MaterialLayoutCreationError&, const MaterialLayoutCreationError&) = default; +}; + +class LegacyNormSpecPipelineLayout +{ +public: + ~LegacyNormSpecPipelineLayout() noexcept; + + LegacyNormSpecPipelineLayout(const LegacyNormSpecPipelineLayout&) = delete; + LegacyNormSpecPipelineLayout& operator=(const LegacyNormSpecPipelineLayout&) = delete; + LegacyNormSpecPipelineLayout(LegacyNormSpecPipelineLayout&&) = delete; + LegacyNormSpecPipelineLayout& operator=(LegacyNormSpecPipelineLayout&&) = delete; + + // These handles are borrowed from this owner and expire with it. Callers + // must not destroy them. The ordered array is returned by value so it + // cannot leave a reference into a destroyed owner. + VkDescriptorSetLayout parameterSetLayout() const noexcept { return mDescriptorSetLayouts[0]; } + VkDescriptorSetLayout sampledImageSetLayout() const noexcept { return mDescriptorSetLayouts[1]; } + std::array descriptorSetLayouts() const noexcept { return mDescriptorSetLayouts; } + VkPipelineLayout pipelineLayout() const noexcept { return mPipelineLayout; } + bool createdOn(VkDevice device) const noexcept { return mDevice == device; } + +private: + friend struct MaterialLayoutFactory; + + explicit LegacyNormSpecPipelineLayout(const MaterialLayoutDevice& device) noexcept; + + VkDevice mDevice = VK_NULL_HANDLE; + PFN_vkDestroyDescriptorSetLayout mDestroyDescriptorSetLayout = nullptr; + PFN_vkDestroyPipelineLayout mDestroyPipelineLayout = nullptr; + std::array mDescriptorSetLayouts{}; + VkPipelineLayout mPipelineLayout = VK_NULL_HANDLE; +}; + +using MaterialLayoutCreationResult = std::variant>; + +MaterialLayoutCreationResult createLegacyNormSpecPipelineLayout(const MaterialLayoutDevice& device) noexcept; + +} // namespace LLRenderVulkanMaterial + +#endif // LL_LLRENDERVULKANMATERIALLAYOUT_H diff --git a/indra/llrender/vulkan/llrendervulkanmaterialmodule.cpp b/indra/llrender/vulkan/llrendervulkanmaterialmodule.cpp new file mode 100644 index 00000000000..120152a8e31 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialmodule.cpp @@ -0,0 +1,137 @@ +/** + * @file llrendervulkanmaterialmodule.cpp + * @brief Transactional Vulkan shader modules for one published material generation. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkanmaterialmodule.h" + +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ +namespace +{ + + constexpr std::uint32_t LEGACY_NORMSPEC_SHADER_INDEX = 1; + + ShaderModuleCreationError failure(ShaderModuleCreationCode code, + std::optional stage = std::nullopt, + VkResult result = VK_SUCCESS) noexcept + { + return { code, stage, result }; + } + + VkShaderModuleCreateInfo createInfo(const LLRenderContract::LoadedShaderModule& module) noexcept + { + VkShaderModuleCreateInfo info{}; + info.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + info.codeSize = module.mWords.size() * sizeof(std::uint32_t); + info.pCode = module.mWords.data(); + return info; + } + +} // namespace + +struct ShaderModuleGenerationFactory +{ + static std::unique_ptr allocate(const ShaderModuleDevice& device, + const LLRenderContract::ShaderGenerationLease& lease) noexcept + { + return std::unique_ptr(new (std::nothrow) ShaderModuleGeneration(device, lease.mHandle, lease.mProgram)); + } + + static VkShaderModule& vertex(ShaderModuleGeneration& generation) noexcept { return generation.mVertexModule; } + + static VkShaderModule& fragment(ShaderModuleGeneration& generation) noexcept { return generation.mFragmentModule; } +}; + +ShaderModuleGeneration::ShaderModuleGeneration(const ShaderModuleDevice& device, LLRenderContract::ShaderHandle handle, + std::shared_ptr program) noexcept : + mDevice(device.mDevice), + mDestroyShaderModule(device.mDispatch.mDestroyShaderModule), + mHandle(handle), + mProgram(std::move(program)) +{ +} + +ShaderModuleGeneration::~ShaderModuleGeneration() noexcept +{ + if (mFragmentModule != VK_NULL_HANDLE) + { + mDestroyShaderModule(mDevice, mFragmentModule, nullptr); + } + if (mVertexModule != VK_NULL_HANDLE) + { + mDestroyShaderModule(mDevice, mVertexModule, nullptr); + } +} + +ShaderModuleCreationResult createLegacyNormSpecShaderModules(const ShaderModuleDevice& device, + const LLRenderContract::ShaderGenerationLease& lease) noexcept +{ + if (device.mDevice == VK_NULL_HANDLE) + { + return failure(ShaderModuleCreationCode::InvalidDevice); + } + if (!device.mDispatch.mCreateShaderModule || !device.mDispatch.mDestroyShaderModule) + { + return failure(ShaderModuleCreationCode::InvalidDispatch); + } + if (!lease.mHandle || lease.mHandle.mIndex != LEGACY_NORMSPEC_SHADER_INDEX || lease.mFrame == 0 || !lease.mProgram || + !LLRenderContract::validLegacyNormSpecProductionShaderProgram(*lease.mProgram)) + { + return failure(ShaderModuleCreationCode::InvalidLease); + } + + std::unique_ptr generation = ShaderModuleGenerationFactory::allocate(device, lease); + if (!generation) + { + return failure(ShaderModuleCreationCode::OwnerAllocationFailure); + } + + const VkShaderModuleCreateInfo vertex_info = createInfo(lease.mProgram->mVertex); + VkShaderModule vertex = VK_NULL_HANDLE; + const VkResult vertex_result = device.mDispatch.mCreateShaderModule(device.mDevice, &vertex_info, nullptr, &vertex); + if (vertex_result != VK_SUCCESS) + { + generation.reset(); + return failure(ShaderModuleCreationCode::CreateFailure, LLRenderContract::ShaderStage::Vertex, vertex_result); + } + if (vertex == VK_NULL_HANDLE) + { + generation.reset(); + return failure(ShaderModuleCreationCode::NullModule, LLRenderContract::ShaderStage::Vertex); + } + ShaderModuleGenerationFactory::vertex(*generation) = vertex; + + const VkShaderModuleCreateInfo fragment_info = createInfo(lease.mProgram->mFragment); + VkShaderModule fragment = VK_NULL_HANDLE; + const VkResult fragment_result = device.mDispatch.mCreateShaderModule(device.mDevice, &fragment_info, nullptr, &fragment); + if (fragment_result != VK_SUCCESS) + { + generation.reset(); + return failure(ShaderModuleCreationCode::CreateFailure, LLRenderContract::ShaderStage::Fragment, fragment_result); + } + if (fragment == VK_NULL_HANDLE) + { + generation.reset(); + return failure(ShaderModuleCreationCode::NullModule, LLRenderContract::ShaderStage::Fragment); + } + ShaderModuleGenerationFactory::fragment(*generation) = fragment; + + return std::move(generation); +} + +} // namespace LLRenderVulkanMaterial diff --git a/indra/llrender/vulkan/llrendervulkanmaterialmodule.h b/indra/llrender/vulkan/llrendervulkanmaterialmodule.h new file mode 100644 index 00000000000..4965925614a --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialmodule.h @@ -0,0 +1,110 @@ +/** + * @file llrendervulkanmaterialmodule.h + * @brief Transactional Vulkan shader modules for one published material generation. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANMATERIALMODULE_H +#define LL_LLRENDERVULKANMATERIALMODULE_H + +#include "llvulkanmaterialpublication.h" + +#include + +#include +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ + +struct ShaderModuleDispatch +{ + PFN_vkCreateShaderModule mCreateShaderModule = nullptr; + PFN_vkDestroyShaderModule mDestroyShaderModule = nullptr; +}; + +// mDevice belongs to a Vulkan 1.1-or-newer logical device. The logical device +// and the implementation addressed by these function pointers must remain +// valid until every module generation has been destroyed. Callers externally +// synchronize host access to the device and modules. +struct ShaderModuleDevice +{ + VkDevice mDevice = VK_NULL_HANDLE; + ShaderModuleDispatch mDispatch; +}; + +enum class ShaderModuleCreationCode : std::uint8_t +{ + InvalidDevice, + InvalidDispatch, + InvalidLease, + OwnerAllocationFailure, + CreateFailure, + NullModule +}; + +struct ShaderModuleCreationError +{ + ShaderModuleCreationCode mCode = ShaderModuleCreationCode::InvalidDevice; + std::optional mStage; + VkResult mResult = VK_SUCCESS; + + friend constexpr bool operator==(const ShaderModuleCreationError&, const ShaderModuleCreationError&) = default; +}; + +class ShaderModuleGeneration +{ +public: + ~ShaderModuleGeneration() noexcept; + + ShaderModuleGeneration(const ShaderModuleGeneration&) = delete; + ShaderModuleGeneration& operator=(const ShaderModuleGeneration&) = delete; + ShaderModuleGeneration(ShaderModuleGeneration&&) = delete; + ShaderModuleGeneration& operator=(ShaderModuleGeneration&&) = delete; + + LLRenderContract::ShaderHandle handle() const noexcept { return mHandle; } + const LLRenderContract::LoadedShaderProgram& program() const noexcept { return *mProgram; } + VkShaderModule vertexModule() const noexcept { return mVertexModule; } + VkShaderModule fragmentModule() const noexcept { return mFragmentModule; } + +private: + friend struct ShaderModuleGenerationFactory; + + ShaderModuleGeneration(const ShaderModuleDevice& device, LLRenderContract::ShaderHandle handle, + std::shared_ptr program) noexcept; + + VkDevice mDevice = VK_NULL_HANDLE; + PFN_vkDestroyShaderModule mDestroyShaderModule = nullptr; + LLRenderContract::ShaderHandle mHandle; + std::shared_ptr mProgram; + VkShaderModule mVertexModule = VK_NULL_HANDLE; + VkShaderModule mFragmentModule = VK_NULL_HANDLE; +}; + +using ShaderModuleCreationResult = std::variant>; + +// The aggregate lease cannot authenticate artifact provenance or its +// publication owner. Its program must originate from the trusted Stage 20 +// packaged artifacts after the build-time SPIR-V validation chain, and the +// lease must come from the matching live LegacyNormSpecShaderPublication. +// Callers invoke this factory before the lease's frame completes. The bounded +// runtime validator rechecks shape and limits, not full SPIR-V validity. The +// frame admits this synchronous creation attempt; it is deliberately not +// retained as a shader-module lifetime or cache key. +ShaderModuleCreationResult createLegacyNormSpecShaderModules(const ShaderModuleDevice& device, + const LLRenderContract::ShaderGenerationLease& lease) noexcept; + +} // namespace LLRenderVulkanMaterial + +#endif // LL_LLRENDERVULKANMATERIALMODULE_H diff --git a/indra/llrender/vulkan/llrendervulkanmaterialpipelinecache.cpp b/indra/llrender/vulkan/llrendervulkanmaterialpipelinecache.cpp new file mode 100644 index 00000000000..613e96c06b0 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialpipelinecache.cpp @@ -0,0 +1,92 @@ +/** + * @file llrendervulkanmaterialpipelinecache.cpp + * @brief Transactional cold Vulkan pipeline-cache ownership for the material prototype. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkanmaterialpipelinecache.h" + +#include +#include + +namespace LLRenderVulkanMaterial +{ +namespace +{ + + MaterialPipelineCacheCreationError failure(MaterialPipelineCacheCreationCode code, VkResult result = VK_SUCCESS) noexcept + { + return { code, result }; + } + +} // namespace + +struct MaterialPipelineCacheFactory +{ + static std::unique_ptr allocate(const MaterialPipelineCacheDevice& device) noexcept + { + return std::unique_ptr(new (std::nothrow) MaterialPipelineCache(device)); + } + + static VkPipelineCache& cache(MaterialPipelineCache& owner) noexcept { return owner.mPipelineCache; } +}; + +MaterialPipelineCache::MaterialPipelineCache(const MaterialPipelineCacheDevice& device) noexcept : + mDevice(device.mDevice), + mDestroyPipelineCache(device.mDispatch.mDestroyPipelineCache) +{ +} + +MaterialPipelineCache::~MaterialPipelineCache() noexcept +{ + if (mPipelineCache != VK_NULL_HANDLE) + { + mDestroyPipelineCache(mDevice, mPipelineCache, nullptr); + } +} + +MaterialPipelineCacheCreationResult createMaterialPipelineCache(const MaterialPipelineCacheDevice& device) noexcept +{ + if (device.mDevice == VK_NULL_HANDLE) + { + return failure(MaterialPipelineCacheCreationCode::InvalidDevice); + } + if (!device.mDispatch.mCreatePipelineCache || !device.mDispatch.mDestroyPipelineCache) + { + return failure(MaterialPipelineCacheCreationCode::InvalidDispatch); + } + + std::unique_ptr owner = MaterialPipelineCacheFactory::allocate(device); + if (!owner) + { + return failure(MaterialPipelineCacheCreationCode::OwnerAllocationFailure); + } + + VkPipelineCacheCreateInfo create_info{}; + create_info.sType = VK_STRUCTURE_TYPE_PIPELINE_CACHE_CREATE_INFO; + + VkPipelineCache cache = VK_NULL_HANDLE; + const VkResult result = device.mDispatch.mCreatePipelineCache(device.mDevice, &create_info, nullptr, &cache); + if (result != VK_SUCCESS) + { + return failure(MaterialPipelineCacheCreationCode::CreateFailure, result); + } + if (cache == VK_NULL_HANDLE) + { + return failure(MaterialPipelineCacheCreationCode::NullPipelineCache); + } + + MaterialPipelineCacheFactory::cache(*owner) = cache; + return std::move(owner); +} + +} // namespace LLRenderVulkanMaterial diff --git a/indra/llrender/vulkan/llrendervulkanmaterialpipelinecache.h b/indra/llrender/vulkan/llrendervulkanmaterialpipelinecache.h new file mode 100644 index 00000000000..dc8edf9574b --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialpipelinecache.h @@ -0,0 +1,95 @@ +/** + * @file llrendervulkanmaterialpipelinecache.h + * @brief Transactional cold Vulkan pipeline-cache ownership for the material prototype. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANMATERIALPIPELINECACHE_H +#define LL_LLRENDERVULKANMATERIALPIPELINECACHE_H + +#include + +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ + +struct MaterialPipelineCacheDispatch +{ + PFN_vkCreatePipelineCache mCreatePipelineCache = nullptr; + PFN_vkDestroyPipelineCache mDestroyPipelineCache = nullptr; +}; + +// The logical device and the implementation addressed by these callbacks +// remain owned by the caller and outlive every returned cache owner. The +// caller must prevent destruction while another host operation uses the cache. +struct MaterialPipelineCacheDevice +{ + VkDevice mDevice = VK_NULL_HANDLE; + MaterialPipelineCacheDispatch mDispatch; +}; + +enum class MaterialPipelineCacheCreationCode : std::uint8_t +{ + InvalidDevice, + InvalidDispatch, + OwnerAllocationFailure, + CreateFailure, + NullPipelineCache +}; + +struct MaterialPipelineCacheCreationError +{ + MaterialPipelineCacheCreationCode mCode = MaterialPipelineCacheCreationCode::InvalidDevice; + VkResult mResult = VK_SUCCESS; + + friend constexpr bool operator==(const MaterialPipelineCacheCreationError&, const MaterialPipelineCacheCreationError&) = default; +}; + +// This owner creates a cold cache with Vulkan's default internally synchronized +// access for later pipeline-creation calls. Destruction and other host +// operations retain their external-synchronization requirements. The owner +// authenticates only the exact VkDevice used for creation. It does not prove +// physical-device capabilities, device features, queue creation, cache warmth, +// or a cache hit. +class MaterialPipelineCache +{ +public: + ~MaterialPipelineCache() noexcept; + + MaterialPipelineCache(const MaterialPipelineCache&) = delete; + MaterialPipelineCache& operator=(const MaterialPipelineCache&) = delete; + MaterialPipelineCache(MaterialPipelineCache&&) = delete; + MaterialPipelineCache& operator=(MaterialPipelineCache&&) = delete; + + VkPipelineCache pipelineCache() const noexcept { return mPipelineCache; } + bool createdOn(VkDevice device) const noexcept { return mDevice == device; } + +private: + friend struct MaterialPipelineCacheFactory; + + explicit MaterialPipelineCache(const MaterialPipelineCacheDevice& device) noexcept; + + VkDevice mDevice = VK_NULL_HANDLE; + PFN_vkDestroyPipelineCache mDestroyPipelineCache = nullptr; + VkPipelineCache mPipelineCache = VK_NULL_HANDLE; +}; + +using MaterialPipelineCacheCreationResult = std::variant>; + +MaterialPipelineCacheCreationResult createMaterialPipelineCache(const MaterialPipelineCacheDevice& device) noexcept; + +} // namespace LLRenderVulkanMaterial + +#endif // LL_LLRENDERVULKANMATERIALPIPELINECACHE_H diff --git a/indra/llrender/vulkan/llrendervulkanmaterialrenderpass.cpp b/indra/llrender/vulkan/llrendervulkanmaterialrenderpass.cpp new file mode 100644 index 00000000000..a5fe0d01088 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialrenderpass.cpp @@ -0,0 +1,173 @@ +/** + * @file llrendervulkanmaterialrenderpass.cpp + * @brief Transactional Vulkan render-pass ownership for the canonical material pass. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkanmaterialrenderpass.h" + +#include +#include + +namespace LLRenderVulkanMaterial +{ +static_assert(std::is_nothrow_copy_constructible_v); + +namespace +{ + + MaterialRenderPassCreationError failure(MaterialRenderPassCreationCode code, VkResult result = VK_SUCCESS) noexcept + { + return { code, result }; + } + + VkAttachmentDescription colorDescription(const MaterialColorAttachmentProfile& color) noexcept + { + VkAttachmentDescription description{}; + description.format = color.mNativeFormat; + description.samples = VK_SAMPLE_COUNT_1_BIT; + description.loadOp = color.mRequiredLoadOp; + description.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + description.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + description.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + description.initialLayout = LegacyNormSpecRenderPass::colorAttachmentLayout(); + description.finalLayout = LegacyNormSpecRenderPass::colorAttachmentLayout(); + return description; + } + + VkAttachmentDescription depthDescription(const MaterialDepthAttachmentProfile& depth) noexcept + { + VkAttachmentDescription description{}; + description.format = depth.mNativeFormat; + description.samples = VK_SAMPLE_COUNT_1_BIT; + description.loadOp = depth.mRequiredLoadOp; + description.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + description.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + description.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + description.initialLayout = LegacyNormSpecRenderPass::depthAttachmentLayout(); + description.finalLayout = LegacyNormSpecRenderPass::depthAttachmentLayout(); + return description; + } + +} // namespace + +struct MaterialRenderPassFactory +{ + static std::unique_ptr allocate(const MaterialRenderPassDevice& device, + const LegacyNormSpecAttachmentProfile& profile) noexcept + { + return std::unique_ptr(new (std::nothrow) LegacyNormSpecRenderPass(device, profile)); + } + + static VkRenderPass& renderPass(LegacyNormSpecRenderPass& owner) noexcept { return owner.mRenderPass; } +}; + +LegacyNormSpecRenderPass::LegacyNormSpecRenderPass(const MaterialRenderPassDevice& device, + const LegacyNormSpecAttachmentProfile& profile) noexcept : + mDevice(device.mDevice), + mDestroyRenderPass(device.mDispatch.mDestroyRenderPass), + mAttachmentProfile(profile) +{ +} + +LegacyNormSpecRenderPass::~LegacyNormSpecRenderPass() noexcept +{ + if (mRenderPass != VK_NULL_HANDLE) + { + mDestroyRenderPass(mDevice, mRenderPass, nullptr); + } +} + +std::array LegacyNormSpecRenderPass::clearValues() const noexcept +{ + std::array values{}; + for (std::size_t slot = 0; slot < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; ++slot) + { + for (std::size_t component = 0; component < mAttachmentProfile.colors()[slot].mClearColor.size(); ++component) + { + values[slot].color.float32[component] = mAttachmentProfile.colors()[slot].mClearColor[component]; + } + } + values[LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT].depthStencil.depth = mAttachmentProfile.depth().mClearDepth; + values[LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT].depthStencil.stencil = mAttachmentProfile.depth().mClearStencil; + return values; +} + +MaterialRenderPassCreationResult createLegacyNormSpecRenderPass(const MaterialRenderPassDevice& device, + const LegacyNormSpecAttachmentProfile& profile) noexcept +{ + if (device.mPhysicalDevice == VK_NULL_HANDLE) + { + return failure(MaterialRenderPassCreationCode::InvalidPhysicalDevice); + } + if (device.mDevice == VK_NULL_HANDLE) + { + return failure(MaterialRenderPassCreationCode::InvalidDevice); + } + if (!device.mDispatch.mCreateRenderPass || !device.mDispatch.mDestroyRenderPass) + { + return failure(MaterialRenderPassCreationCode::InvalidDispatch); + } + if (!profile.selectedFor(device.mPhysicalDevice)) + { + return failure(MaterialRenderPassCreationCode::ProfilePhysicalDeviceMismatch); + } + + std::array attachments{}; + std::array color_references{}; + for (std::uint32_t slot = 0; slot < LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; ++slot) + { + attachments[slot] = colorDescription(profile.colors()[slot]); + color_references[slot].attachment = slot; + color_references[slot].layout = LegacyNormSpecRenderPass::colorAttachmentLayout(); + } + attachments[LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT] = depthDescription(profile.depth()); + + VkAttachmentReference depth_reference{}; + depth_reference.attachment = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + depth_reference.layout = LegacyNormSpecRenderPass::depthAttachmentLayout(); + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT; + subpass.pColorAttachments = color_references.data(); + subpass.pDepthStencilAttachment = &depth_reference; + + VkRenderPassCreateInfo create_info{}; + create_info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + create_info.attachmentCount = LEGACY_NORMSPEC_RENDER_PASS_ATTACHMENT_COUNT; + create_info.pAttachments = attachments.data(); + create_info.subpassCount = 1; + create_info.pSubpasses = &subpass; + + std::unique_ptr owner = MaterialRenderPassFactory::allocate(device, profile); + if (!owner) + { + return failure(MaterialRenderPassCreationCode::OwnerAllocationFailure); + } + + VkRenderPass render_pass = VK_NULL_HANDLE; + const VkResult result = device.mDispatch.mCreateRenderPass(device.mDevice, &create_info, nullptr, &render_pass); + if (result != VK_SUCCESS) + { + return failure(MaterialRenderPassCreationCode::CreateFailure, result); + } + if (render_pass == VK_NULL_HANDLE) + { + return failure(MaterialRenderPassCreationCode::NullRenderPass); + } + + MaterialRenderPassFactory::renderPass(*owner) = render_pass; + return owner; +} + +} // namespace LLRenderVulkanMaterial diff --git a/indra/llrender/vulkan/llrendervulkanmaterialrenderpass.h b/indra/llrender/vulkan/llrendervulkanmaterialrenderpass.h new file mode 100644 index 00000000000..2236acfa0c5 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkanmaterialrenderpass.h @@ -0,0 +1,121 @@ +/** + * @file llrendervulkanmaterialrenderpass.h + * @brief Transactional Vulkan render-pass ownership for the canonical material pass. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANMATERIALRENDERPASS_H +#define LL_LLRENDERVULKANMATERIALRENDERPASS_H + +#include "llrendervulkanmaterialattachment.h" + +#include + +#include +#include +#include +#include + +namespace LLRenderVulkanMaterial +{ + +inline constexpr std::uint32_t LEGACY_NORMSPEC_RENDER_PASS_ATTACHMENT_COUNT = LEGACY_NORMSPEC_COLOR_ATTACHMENT_COUNT + 1; + +struct MaterialRenderPassDispatch +{ + PFN_vkCreateRenderPass mCreateRenderPass = nullptr; + PFN_vkDestroyRenderPass mDestroyRenderPass = nullptr; +}; + +// Both handles and the implementation addressed by these callbacks remain +// owned by the caller. The caller guarantees a Vulkan 1.1 device created from +// mPhysicalDevice, with at least one graphics-capable queue family, and +// callbacks valid for mDevice. Raw handles cannot authenticate those +// relationships. Both handles remain valid until every returned owner has +// been destroyed. Host access is externally synchronized. +struct MaterialRenderPassDevice +{ + VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE; + VkDevice mDevice = VK_NULL_HANDLE; + MaterialRenderPassDispatch mDispatch; +}; + +enum class MaterialRenderPassCreationCode : std::uint8_t +{ + InvalidPhysicalDevice, + InvalidDevice, + InvalidDispatch, + ProfilePhysicalDeviceMismatch, + OwnerAllocationFailure, + CreateFailure, + NullRenderPass +}; + +struct MaterialRenderPassCreationError +{ + MaterialRenderPassCreationCode mCode = MaterialRenderPassCreationCode::InvalidPhysicalDevice; + VkResult mResult = VK_SUCCESS; + + friend constexpr bool operator==(const MaterialRenderPassCreationError&, const MaterialRenderPassCreationError&) = default; +}; + +// This owner describes one shared deferred pass generation. Its CLEAR load +// operations must not be interpreted as permission to begin one render pass +// per material draw. Borrowed handles and profile references expire with the +// owner. The caller must wait for all submitted native users before destroy. +class LegacyNormSpecRenderPass +{ +public: + ~LegacyNormSpecRenderPass() noexcept; + + LegacyNormSpecRenderPass(const LegacyNormSpecRenderPass&) = delete; + LegacyNormSpecRenderPass& operator=(const LegacyNormSpecRenderPass&) = delete; + LegacyNormSpecRenderPass(LegacyNormSpecRenderPass&&) = delete; + LegacyNormSpecRenderPass& operator=(LegacyNormSpecRenderPass&&) = delete; + + VkRenderPass renderPass() const noexcept { return mRenderPass; } + bool createdOn(VkDevice device) const noexcept { return mDevice == device; } + bool selectedFrom(VkPhysicalDevice physical_device) const noexcept { return mAttachmentProfile.selectedFor(physical_device); } + + // Creation supplies no explicit subpass dependencies and performs no + // automatic layout transitions. A future image-aware encoder must + // transition the actual images into these attachment layouts before begin + // and out to their consumer layouts afterward, including all required + // synchronization and queue-family ownership transfers. + static constexpr VkImageLayout colorAttachmentLayout() noexcept { return VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; } + static constexpr VkImageLayout depthAttachmentLayout() noexcept { return VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; } + + const LegacyNormSpecAttachmentProfile& attachmentProfile() const noexcept { return mAttachmentProfile; } + + // VkRenderPass creation does not consume clear values. A later pass-begin + // owner must use this complete ordered array over the full render area. + std::array clearValues() const noexcept; + +private: + friend struct MaterialRenderPassFactory; + + LegacyNormSpecRenderPass(const MaterialRenderPassDevice& device, const LegacyNormSpecAttachmentProfile& profile) noexcept; + + VkDevice mDevice = VK_NULL_HANDLE; + PFN_vkDestroyRenderPass mDestroyRenderPass = nullptr; + VkRenderPass mRenderPass = VK_NULL_HANDLE; + LegacyNormSpecAttachmentProfile mAttachmentProfile; +}; + +using MaterialRenderPassCreationResult = std::variant>; + +MaterialRenderPassCreationResult createLegacyNormSpecRenderPass(const MaterialRenderPassDevice& device, + const LegacyNormSpecAttachmentProfile& profile) noexcept; + +} // namespace LLRenderVulkanMaterial + +#endif // LL_LLRENDERVULKANMATERIALRENDERPASS_H diff --git a/indra/llrender/vulkan/llrendervulkantextureupload.cpp b/indra/llrender/vulkan/llrendervulkantextureupload.cpp new file mode 100644 index 00000000000..000426c3289 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkantextureupload.cpp @@ -0,0 +1,1150 @@ +/** + * @file llrendervulkantextureupload.cpp + * @brief Vulkan replay of the canonical streamed texture-upload packet. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkantextureupload.h" + +#include +#include +#include +#include +#include +#include + +namespace LLRenderVulkanTextureUpload +{ +namespace +{ + +constexpr VkImageUsageFlags STREAMED_IMAGE_USAGE = + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT; +constexpr VkImageUsageFlags OUTPUT_IMAGE_USAGE = + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; +constexpr VkColorComponentFlags COLOR_WRITE_MASK = + VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; +constexpr LLRenderContract::Extent2D RESIDENT_EXTENT{ + LLRenderContract::TEXTURE_UPLOAD_RESIDENT_WIDTH, + LLRenderContract::TEXTURE_UPLOAD_RESIDENT_HEIGHT +}; +constexpr LLRenderContract::Extent2D LOGICAL_EXTENT{ + LLRenderContract::TEXTURE_UPLOAD_LOGICAL_WIDTH, + LLRenderContract::TEXTURE_UPLOAD_LOGICAL_HEIGHT +}; +constexpr LLRenderContract::Extent2D OUTPUT_EXTENT{ + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_WIDTH, + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_HEIGHT +}; + +bool nonzeroIdentity(const ShaderIdentityToken& identity) +{ + return std::any_of(identity.begin(), identity.end(), + [](std::uint8_t byte) { return byte != 0; }); +} + +bool sameExtent(LLRenderContract::Extent2D left, LLRenderContract::Extent2D right) +{ + return left.mWidth == right.mWidth && left.mHeight == right.mHeight; +} + +bool identityComponents(const VkComponentMapping& components) +{ + return components.r == VK_COMPONENT_SWIZZLE_IDENTITY && + components.g == VK_COMPONENT_SWIZZLE_IDENTITY && + components.b == VK_COMPONENT_SWIZZLE_IDENTITY && + components.a == VK_COMPONENT_SWIZZLE_IDENTITY; +} + +bool completeBuffer(const BufferBinding& binding) +{ + return binding.mBuffer != VK_NULL_HANDLE && binding.mMemory != VK_NULL_HANDLE && + binding.mOwnershipToken != 0 && binding.mMapped != nullptr && binding.mSize != 0 && + binding.mAllocationSize >= binding.mSize && binding.mMemoryOffset == 0 && + binding.mUsage != 0 && binding.mSharingMode != VK_SHARING_MODE_MAX_ENUM && + (binding.mMemoryProperties & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0; +} + +bool completeImage(const ImageBinding& binding) +{ + return binding.mImage != VK_NULL_HANDLE && binding.mView != VK_NULL_HANDLE && + binding.mMemory != VK_NULL_HANDLE && binding.mOwnershipToken != 0 && + binding.mAllocationSize != 0 && + binding.mMemoryOffset == 0 && binding.mImageType != VK_IMAGE_TYPE_MAX_ENUM && + binding.mFormat != VK_FORMAT_UNDEFINED && binding.mResidentExtent.mWidth != 0 && + binding.mResidentExtent.mHeight != 0 && binding.mLogicalExtent.mWidth != 0 && + binding.mLogicalExtent.mHeight != 0 && binding.mMipLevels != 0 && + binding.mArrayLayers != 0 && binding.mSamples != VK_SAMPLE_COUNT_FLAG_BITS_MAX_ENUM && + binding.mTiling != VK_IMAGE_TILING_MAX_ENUM && binding.mUsage != 0 && + binding.mSharingMode != VK_SHARING_MODE_MAX_ENUM && binding.mAspect != 0 && + binding.mViewType != VK_IMAGE_VIEW_TYPE_MAX_ENUM && + binding.mViewFormat != VK_FORMAT_UNDEFINED && binding.mViewRange.levelCount != 0 && + binding.mViewRange.layerCount != 0; +} + +bool completePipeline(const PipelineBinding& binding) +{ + return !binding.mProgram.mName.empty() && binding.mPipeline != VK_NULL_HANDLE && + binding.mLayout != VK_NULL_HANDLE && binding.mRenderPass != VK_NULL_HANDLE && + binding.mFramebuffer != VK_NULL_HANDLE && binding.mDescriptorSet != VK_NULL_HANDLE && + binding.mOwnershipToken != 0 && + binding.mExtent.mWidth != 0 && binding.mExtent.mHeight != 0 && + binding.mColorView != VK_NULL_HANDLE && binding.mColorFormat != VK_FORMAT_UNDEFINED && + nonzeroIdentity(binding.mVertexShaderIdentity) && + nonzeroIdentity(binding.mFragmentShaderIdentity) && + !binding.mVertexEntryPoint.empty() && !binding.mFragmentEntryPoint.empty(); +} + +bool pristineLifecycle(const LifecycleLedger& ledger) +{ + return !ledger.mCompletionPending && ledger.mCompletionCount == 0 && + !ledger.mCompletedDestination && ledger.mCompletedRevision == 0 && + ledger.mCompletedFrame == 0 && ledger.mRetirementCount == 0 && + !ledger.mRetiredResource && ledger.mRetirementFrame == 0; +} + +bool canonicalContext(const ExecutionContext& context) +{ + constexpr VkQueueFlags REQUIRED_QUEUE_FLAGS = + VK_QUEUE_GRAPHICS_BIT | VK_QUEUE_TRANSFER_BIT; + return context.mDevice != VK_NULL_HANDLE && context.mCommandPool != VK_NULL_HANDLE && + context.mCommandBuffer != VK_NULL_HANDLE && context.mQueue != VK_NULL_HANDLE && + context.mOwnershipToken != 0 && + context.mQueueFamilyIndex != VK_QUEUE_FAMILY_IGNORED && + (context.mQueueFamilyFlags & REQUIRED_QUEUE_FLAGS) == REQUIRED_QUEUE_FLAGS && + context.mQueueCount == 1 && context.mQueueIndex == 0 && + context.mCommandPoolQueueFamilyIndex == context.mQueueFamilyIndex && + (context.mCommandPoolFlags & VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT) != 0 && + context.mCommandBufferLevel == VK_COMMAND_BUFFER_LEVEL_PRIMARY && + context.mRecordingAttemptCount && context.mSubmissionCount && + context.mRecordingAttemptCount != context.mSubmissionCount && + nonzeroIdentity(context.mRequiredVertexShaderIdentity) && + nonzeroIdentity(context.mRequiredFragmentShaderIdentity); +} + +bool sameImageRange(const VkImageSubresourceRange& range, std::uint32_t mip_levels) +{ + return range.aspectMask == VK_IMAGE_ASPECT_COLOR_BIT && range.baseMipLevel == 0 && + range.levelCount == mip_levels && range.baseArrayLayer == 0 && + range.layerCount == 1; +} + +bool canonicalBuffer(const BufferBinding& binding, VkDeviceSize size, + VkBufferUsageFlags usage) +{ + return completeBuffer(binding) && binding.mSize == size && + binding.mAllocationSize >= size && binding.mCreateFlags == 0 && + binding.mUsage == usage && binding.mSharingMode == VK_SHARING_MODE_EXCLUSIVE; +} + +bool canonicalImage(const ImageBinding& image, LLRenderContract::Extent2D resident_extent, + LLRenderContract::Extent2D logical_extent, std::uint32_t discard, + std::uint32_t mip_levels, VkImageUsageFlags usage) +{ + return completeImage(image) && image.mCreateFlags == 0 && + image.mImageType == VK_IMAGE_TYPE_2D && image.mFormat == VK_FORMAT_R8G8B8A8_UNORM && + sameExtent(image.mResidentExtent, resident_extent) && + sameExtent(image.mLogicalExtent, logical_extent) && + image.mResidentDiscard == discard && image.mMipLevels == mip_levels && + image.mArrayLayers == 1 && image.mSamples == VK_SAMPLE_COUNT_1_BIT && + image.mTiling == VK_IMAGE_TILING_OPTIMAL && image.mUsage == usage && + image.mSharingMode == VK_SHARING_MODE_EXCLUSIVE && + image.mAspect == VK_IMAGE_ASPECT_COLOR_BIT && + image.mLayout == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL && + image.mViewType == VK_IMAGE_VIEW_TYPE_2D && + image.mViewFormat == VK_FORMAT_R8G8B8A8_UNORM && + identityComponents(image.mViewComponents) && + sameImageRange(image.mViewRange, mip_levels); +} + +bool canonicalSampler(const SamplerBinding& sampler) +{ + return sampler.mSampler != VK_NULL_HANDLE && sampler.mCreateFlags == 0 && + sampler.mMinFilter == VK_FILTER_LINEAR && sampler.mMagFilter == VK_FILTER_LINEAR && + sampler.mMipmapMode == VK_SAMPLER_MIPMAP_MODE_LINEAR && + sampler.mAddressU == VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE && + sampler.mAddressV == VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE && + sampler.mAddressW == VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE && + sampler.mMipLodBias == 0.f && sampler.mAnisotropyEnable == VK_FALSE && + sampler.mMaxAnisotropy == 1.f && sampler.mCompareEnable == VK_FALSE && + sampler.mCompareOp == VK_COMPARE_OP_ALWAYS && sampler.mMinLod == 0.f && + sampler.mMaxLod == 2.f && + sampler.mBorderColor == VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK && + sampler.mUnnormalizedCoordinates == VK_FALSE; +} + +bool canonicalStencilState(const VkStencilOpState& stencil) +{ + return stencil.failOp == VK_STENCIL_OP_KEEP && stencil.passOp == VK_STENCIL_OP_KEEP && + stencil.depthFailOp == VK_STENCIL_OP_KEEP && + stencil.compareOp == VK_COMPARE_OP_NEVER && stencil.compareMask == 0 && + stencil.writeMask == 0 && stencil.reference == 0; +} + +bool canonicalPipeline(const PipelineBinding& pipeline, const ImageBinding& replacement, + const ImageBinding& output, const SamplerBinding& sampler, + const ExecutionContext& context) +{ + const SampledDescriptorBinding& descriptor = pipeline.mSampledDescriptor; + const VertexBindingState& vertex_binding = pipeline.mVertexBinding; + const VertexAttributeState& attribute = pipeline.mVertexAttribute; + const RasterState& raster = pipeline.mRaster; + const MultisampleState& multisample = pipeline.mMultisample; + const DepthStencilState& depth = pipeline.mDepthStencil; + const ColorTargetState& color = pipeline.mColorTarget; + + return pipeline.mProgram.mName == "contract.sample-texture" && + pipeline.mProgram.mVariant == 0 && sameExtent(pipeline.mExtent, OUTPUT_EXTENT) && + descriptor.mSet == 0 && descriptor.mBinding == 0 && + descriptor.mType == VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER && + descriptor.mStages == VK_SHADER_STAGE_FRAGMENT_BIT && + descriptor.mView == replacement.mView && descriptor.mSampler == sampler.mSampler && + descriptor.mLayout == VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL && + pipeline.mColorView == output.mView && + pipeline.mColorFormat == VK_FORMAT_R8G8B8A8_UNORM && + pipeline.mColorSamples == VK_SAMPLE_COUNT_1_BIT && + pipeline.mColorLoadOp == VK_ATTACHMENT_LOAD_OP_DONT_CARE && + pipeline.mColorStoreOp == VK_ATTACHMENT_STORE_OP_STORE && + pipeline.mStencilLoadOp == VK_ATTACHMENT_LOAD_OP_DONT_CARE && + pipeline.mStencilStoreOp == VK_ATTACHMENT_STORE_OP_DONT_CARE && + pipeline.mColorInitialLayout == VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL && + pipeline.mColorFinalLayout == VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL && + pipeline.mSubpassDependencyCount == 0 && + vertex_binding.mBinding == 0 && vertex_binding.mStride == 16 && + vertex_binding.mInputRate == VK_VERTEX_INPUT_RATE_VERTEX && + attribute.mLocation == 0 && attribute.mBinding == 0 && + attribute.mFormat == VK_FORMAT_R32G32B32_SFLOAT && attribute.mOffset == 0 && + raster.mTopology == VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST && + raster.mPrimitiveRestartEnable == VK_FALSE && raster.mDepthClampEnable == VK_FALSE && + raster.mRasterizerDiscardEnable == VK_FALSE && + raster.mPolygonMode == VK_POLYGON_MODE_FILL && raster.mCullMode == VK_CULL_MODE_NONE && + raster.mFrontFace == VK_FRONT_FACE_COUNTER_CLOCKWISE && + raster.mDepthBiasEnable == VK_FALSE && raster.mDepthBiasConstantFactor == 0.f && + raster.mDepthBiasClamp == 0.f && raster.mDepthBiasSlopeFactor == 0.f && + raster.mLineWidth == 1.f && + multisample.mRasterizationSamples == VK_SAMPLE_COUNT_1_BIT && + multisample.mSampleShadingEnable == VK_FALSE && + multisample.mMinSampleShading == 0.f && + multisample.mSampleMask == 0xffffffffu && + multisample.mAlphaToCoverageEnable == VK_FALSE && + multisample.mAlphaToOneEnable == VK_FALSE && + depth.mDepthTestEnable == VK_FALSE && depth.mDepthWriteEnable == VK_FALSE && + depth.mDepthCompareOp == VK_COMPARE_OP_LESS_OR_EQUAL && + depth.mDepthBoundsTestEnable == VK_FALSE && depth.mStencilTestEnable == VK_FALSE && + canonicalStencilState(depth.mFront) && canonicalStencilState(depth.mBack) && + depth.mMinDepthBounds == 0.f && depth.mMaxDepthBounds == 1.f && + color.mFormat == VK_FORMAT_R8G8B8A8_UNORM && color.mBlendEnable == VK_FALSE && + color.mSrcColorBlendFactor == VK_BLEND_FACTOR_ONE && + color.mDstColorBlendFactor == VK_BLEND_FACTOR_ZERO && + color.mColorBlendOp == VK_BLEND_OP_ADD && + color.mSrcAlphaBlendFactor == VK_BLEND_FACTOR_ONE && + color.mDstAlphaBlendFactor == VK_BLEND_FACTOR_ZERO && + color.mAlphaBlendOp == VK_BLEND_OP_ADD && color.mWriteMask == COLOR_WRITE_MASK && + pipeline.mLogicOpEnable == VK_FALSE && pipeline.mLogicOp == VK_LOGIC_OP_COPY && + pipeline.mBlendConstants == std::array{} && + pipeline.mDynamicViewport == VK_TRUE && pipeline.mDynamicScissor == VK_TRUE && + pipeline.mVertexShaderIdentity == context.mRequiredVertexShaderIdentity && + pipeline.mFragmentShaderIdentity == context.mRequiredFragmentShaderIdentity && + pipeline.mVertexEntryPoint == "main" && pipeline.mFragmentEntryPoint == "main"; +} + +bool distinctImageResources(const std::array& images) +{ + for (std::size_t left = 0; left < images.size(); ++left) + { + for (std::size_t right = left + 1; right < images.size(); ++right) + { + if (images[left]->mImage == images[right]->mImage || + images[left]->mView == images[right]->mView || + images[left]->mMemory == images[right]->mMemory) + { + return false; + } + } + } + return true; +} + +bool distinctBufferResources(const std::array& buffers) +{ + for (std::size_t left = 0; left < buffers.size(); ++left) + { + for (std::size_t right = left + 1; right < buffers.size(); ++right) + { + if (buffers[left]->mBuffer == buffers[right]->mBuffer || + buffers[left]->mMemory == buffers[right]->mMemory || + buffers[left]->mMapped == buffers[right]->mMapped) + { + return false; + } + } + } + return true; +} + +bool disjointMemory(const std::array& buffers, + const std::array& images) +{ + for (const BufferBinding* buffer : buffers) + { + for (const ImageBinding* image : images) + { + if (buffer->mMemory == image->mMemory) + { + return false; + } + } + } + return true; +} + +std::array +screenTriangleBytes(const LLRenderContract::TextureUploadFixture& fixture) +{ + std::array bytes{}; + static_assert(sizeof(fixture.mScreenTriangle) == SCREEN_TRIANGLE_BYTE_SIZE); + std::memcpy(bytes.data(), fixture.mScreenTriangle.data(), bytes.size()); + return bytes; +} + +struct Prepared +{ + LLRenderContract::StreamingUploadInputs mInputs; + LLRenderContract::TextureUploadFixture mFixture; + const BufferBinding* mScreen = nullptr; + const ImageBinding* mOld = nullptr; + const ImageBinding* mReplacement = nullptr; + const ImageBinding* mOutput = nullptr; + const SamplerBinding* mSampler = nullptr; + const PipelineBinding* mPipeline = nullptr; + const TransferResources* mTransfer = nullptr; + LifecycleLedger* mLifecycle = nullptr; +}; + +std::optional prepare(const LLRenderContract::FrameSnapshot& frame, + Registry& registry, const ExecutionContext& context, + std::string& error) +{ + auto inputs = LLRenderContract::decodeStreamingUploadFrame(frame); + if (!inputs) + { + error = "packet is not the canonical streamed texture upload"; + return std::nullopt; + } + if (inputs->mFrame != LLRenderContract::TEXTURE_UPLOAD_DIAGNOSTIC_FRAME || + inputs->mHandles != LLRenderContract::StreamingUploadHandles{}) + { + error = "packet frame or handles do not match the diagnostic case"; + return std::nullopt; + } + if (!canonicalContext(context)) + { + error = "execution context is incomplete"; + return std::nullopt; + } + + Prepared result; + result.mInputs = std::move(*inputs); + result.mFixture = LLRenderContract::makeTextureUploadFixture(); + const LLRenderContract::StreamingUploadHandles& handles = result.mInputs.mHandles; + result.mScreen = registry.resolve(handles.mScreenTriangle); + result.mOld = registry.resolveRegisteredImage(handles.mOldImage); + result.mReplacement = registry.resolveRegisteredImage(handles.mReplacementImage); + result.mOutput = registry.resolveOutput(handles.mOutput); + result.mSampler = registry.resolve(handles.mSampler); + result.mPipeline = registry.resolve(handles.mPipeline, frame.mPipelines.front().mProgram); + result.mTransfer = registry.transferResources(); + result.mLifecycle = registry.lifecycle(); + + if (!result.mScreen || !result.mOld || !result.mReplacement || !result.mOutput || + !result.mSampler || !result.mPipeline || !result.mTransfer || !result.mLifecycle) + { + error = "registry cannot resolve every exact resource generation"; + return std::nullopt; + } + if (result.mScreen->mOwnershipToken != context.mOwnershipToken || + result.mOld->mOwnershipToken != context.mOwnershipToken || + result.mReplacement->mOwnershipToken != context.mOwnershipToken || + result.mOutput->mOwnershipToken != context.mOwnershipToken || + result.mSampler->mOwnershipToken != context.mOwnershipToken || + result.mPipeline->mOwnershipToken != context.mOwnershipToken || + result.mTransfer->mStaging.mOwnershipToken != context.mOwnershipToken || + result.mTransfer->mReadback.mOwnershipToken != context.mOwnershipToken) + { + error = "native resources do not share the execution context owner"; + return std::nullopt; + } + if (result.mLifecycle->mCurrentImage != handles.mOldImage || + result.mLifecycle->mLastRevision != LLRenderContract::TEXTURE_UPLOAD_PRIOR_REVISION || + result.mInputs.mRevision <= result.mLifecycle->mLastRevision || + !pristineLifecycle(*result.mLifecycle) || !registry.isResolvable(handles.mOldImage) || + registry.isResolvable(handles.mReplacementImage)) + { + error = "lifecycle ledger is not the unpublished replacement state"; + return std::nullopt; + } + + const std::array expected_screen = + screenTriangleBytes(result.mFixture); + if (!canonicalBuffer(*result.mScreen, SCREEN_TRIANGLE_BYTE_SIZE, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT) || + !result.mScreen->mHasFixtureBytes || + result.mScreen->mFixtureBytes != expected_screen || + std::memcmp(result.mScreen->mMapped, expected_screen.data(), expected_screen.size()) != 0) + { + error = "screen-triangle buffer metadata or bytes do not match the fixture"; + return std::nullopt; + } + if (!canonicalBuffer(result.mTransfer->mStaging, STAGING_BYTE_SIZE, + VK_BUFFER_USAGE_TRANSFER_SRC_BIT) || + result.mTransfer->mStaging.mHasFixtureBytes || + !canonicalBuffer(result.mTransfer->mReadback, READBACK_BYTE_SIZE, + VK_BUFFER_USAGE_TRANSFER_DST_BIT) || + result.mTransfer->mReadback.mHasFixtureBytes) + { + error = "staging or readback buffer metadata is not canonical"; + return std::nullopt; + } + + if (!canonicalImage(*result.mOld, RESIDENT_EXTENT, LOGICAL_EXTENT, + LLRenderContract::TEXTURE_UPLOAD_RESIDENT_DISCARD, + LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS, STREAMED_IMAGE_USAGE) || + !result.mOld->mHasPreExecutionMipSnapshot || + result.mOld->mPreExecutionMipRGBA8 != result.mFixture.mOldMipRGBA8 || + !canonicalImage(*result.mReplacement, RESIDENT_EXTENT, LOGICAL_EXTENT, + LLRenderContract::TEXTURE_UPLOAD_RESIDENT_DISCARD, + LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS, STREAMED_IMAGE_USAGE) || + result.mReplacement->mHasPreExecutionMipSnapshot || + !canonicalImage(*result.mOutput, OUTPUT_EXTENT, OUTPUT_EXTENT, 0, 1, + OUTPUT_IMAGE_USAGE) || result.mOutput->mHasPreExecutionMipSnapshot) + { + error = "image metadata does not match the streamed upload"; + return std::nullopt; + } + const std::array images{ + result.mOld, result.mReplacement, result.mOutput + }; + const std::array buffers{ + result.mScreen, &result.mTransfer->mStaging, &result.mTransfer->mReadback + }; + if (!distinctImageResources(images) || !distinctBufferResources(buffers) || + !disjointMemory(buffers, images)) + { + error = "diagnostic native resources alias"; + return std::nullopt; + } + if (!canonicalSampler(*result.mSampler)) + { + error = "sampler metadata does not match the streamed upload"; + return std::nullopt; + } + if (!completePipeline(*result.mPipeline) || + !canonicalPipeline(*result.mPipeline, *result.mReplacement, *result.mOutput, + *result.mSampler, context)) + { + error = "pipeline resources, state, or shader identity do not match"; + return std::nullopt; + } + return result; +} + +VkImageMemoryBarrier imageBarrier(const ImageBinding& image, VkImageLayout old_layout, + VkImageLayout new_layout, VkAccessFlags source_access, + VkAccessFlags destination_access, std::uint32_t base_mip, + std::uint32_t mip_count) +{ + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.srcAccessMask = source_access; + barrier.dstAccessMask = destination_access; + barrier.oldLayout = old_layout; + barrier.newLayout = new_layout; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = image.mImage; + barrier.subresourceRange = { + VK_IMAGE_ASPECT_COLOR_BIT, base_mip, mip_count, 0, 1 + }; + return barrier; +} + +VkBufferMemoryBarrier bufferBarrier(const BufferBinding& buffer, + VkAccessFlags source_access, + VkAccessFlags destination_access) +{ + VkBufferMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; + barrier.srcAccessMask = source_access; + barrier.dstAccessMask = destination_access; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.buffer = buffer.mBuffer; + barrier.offset = 0; + barrier.size = buffer.mSize; + return barrier; +} + +VkBufferImageCopy imageCopy(VkDeviceSize offset, std::uint32_t mip, + std::uint32_t width, std::uint32_t height, + std::uint32_t destination_y = 0) +{ + VkBufferImageCopy copy{}; + copy.bufferOffset = offset; + copy.bufferRowLength = 0; + copy.bufferImageHeight = 0; + copy.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, mip, 0, 1 }; + copy.imageOffset = { 0, static_cast(destination_y), 0 }; + copy.imageExtent = { width, height, 1 }; + return copy; +} + +VkImageBlit mipBlit(std::uint32_t source_mip, std::uint32_t source_width, + std::uint32_t source_height, std::uint32_t destination_width, + std::uint32_t destination_height) +{ + VkImageBlit blit{}; + blit.srcSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, source_mip, 0, 1 }; + blit.srcOffsets[0] = { 0, 0, 0 }; + blit.srcOffsets[1] = { static_cast(source_width), + static_cast(source_height), 1 }; + blit.dstSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, source_mip + 1, 0, 1 }; + blit.dstOffsets[0] = { 0, 0, 0 }; + blit.dstOffsets[1] = { static_cast(destination_width), + static_cast(destination_height), 1 }; + return blit; +} + +bool mappedRangeOperation(VkDevice device, const BufferBinding& buffer, bool flush, + std::string& error) +{ + if ((buffer.mMemoryProperties & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) != 0) + { + return true; + } + VkMappedMemoryRange range{}; + range.sType = VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE; + range.memory = buffer.mMemory; + range.offset = 0; + range.size = VK_WHOLE_SIZE; + const VkResult result = flush ? vkFlushMappedMemoryRanges(device, 1, &range) + : vkInvalidateMappedMemoryRanges(device, 1, &range); + if (result != VK_SUCCESS) + { + error = flush ? "vkFlushMappedMemoryRanges failed" + : "vkInvalidateMappedMemoryRanges failed"; + return false; + } + return true; +} + +std::vector normalizedSource( + const LLRenderContract::StreamingUploadInputs& inputs) +{ + constexpr std::size_t tight_row = + LLRenderContract::TEXTURE_UPLOAD_RESIDENT_WIDTH * + LLRenderContract::TEXTURE_UPLOAD_CHANNELS; + std::vector normalized( + tight_row * LLRenderContract::TEXTURE_UPLOAD_RESIDENT_HEIGHT); + for (std::size_t source_y = 0; + source_y < LLRenderContract::TEXTURE_UPLOAD_RESIDENT_HEIGHT; ++source_y) + { + const std::size_t destination_y = + LLRenderContract::TEXTURE_UPLOAD_RESIDENT_HEIGHT - 1 - source_y; + std::memcpy(normalized.data() + destination_y * tight_row, + inputs.mPixels.data() + source_y * + LLRenderContract::TEXTURE_UPLOAD_ROW_PITCH, + tight_row); + } + return normalized; +} + +bool hasMultipleChangedTexels(const std::uint8_t* before, std::size_t size, + const std::vector& after) +{ + if (after.size() != size || size < LLRenderContract::TEXTURE_UPLOAD_CHANNELS * 2 || + size % LLRenderContract::TEXTURE_UPLOAD_CHANNELS != 0) + { + return false; + } + std::optional first_changed; + for (std::size_t offset = 0; offset < size; + offset += LLRenderContract::TEXTURE_UPLOAD_CHANNELS) + { + if (std::memcmp(before + offset, after.data() + offset, + LLRenderContract::TEXTURE_UPLOAD_CHANNELS) == 0) + { + continue; + } + if (!first_changed) + { + first_changed = offset; + continue; + } + if (std::memcmp(after.data() + *first_changed, after.data() + offset, + LLRenderContract::TEXTURE_UPLOAD_CHANNELS) != 0) + { + return true; + } + } + return false; +} + +bool validateContent(const Prepared& prepared, const ExecutionResult& completed) +{ + if (completed.mMipRGBA8[0] != normalizedSource(prepared.mInputs) || + completed.mSampledRGBA8 != completed.mMipRGBA8[1]) + { + return false; + } + for (std::size_t mip = 0; mip < LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS; ++mip) + { + const std::size_t offset = LLRenderContract::TEXTURE_UPLOAD_MIP_BYTE_OFFSETS[mip]; + const std::size_t size = LLRenderContract::TEXTURE_UPLOAD_MIP_BYTE_SIZES[mip]; + if (!hasMultipleChangedTexels( + prepared.mFixture.mReplacementSentinelMipRGBA8.data() + offset, + size, completed.mMipRGBA8[mip])) + { + return false; + } + } + return hasMultipleChangedTexels(prepared.mFixture.mOutputSentinelRGBA8.data(), + prepared.mFixture.mOutputSentinelRGBA8.size(), + completed.mSampledRGBA8); +} + +} // namespace + +bool Registry::addScreenTriangle(LLRenderContract::BufferHandle handle, + BufferBinding binding) +{ + if (!handle || mHasScreenTriangle || !completeBuffer(binding) || + (mHasTransferResources && + (binding.mBuffer == mTransferResources.mStaging.mBuffer || + binding.mBuffer == mTransferResources.mReadback.mBuffer || + binding.mMemory == mTransferResources.mStaging.mMemory || + binding.mMemory == mTransferResources.mReadback.mMemory))) + { + return false; + } + mScreenHandle = handle; + mScreenTriangle = std::move(binding); + mHasScreenTriangle = true; + return true; +} + +bool Registry::addImageGenerations(LLRenderContract::ImageHandle old_handle, + ImageBinding old_image, + LLRenderContract::ImageHandle replacement_handle, + ImageBinding replacement_image) +{ + if (!old_handle || !replacement_handle || mHasImageGenerations || + !completeImage(old_image) || !completeImage(replacement_image) || + !old_image.mHasPreExecutionMipSnapshot || + replacement_image.mHasPreExecutionMipSnapshot || + old_handle.mIndex != replacement_handle.mIndex || + old_handle.mGeneration == std::numeric_limits::max() || + replacement_handle.mGeneration != old_handle.mGeneration + 1 || + old_image.mImage == replacement_image.mImage || old_image.mView == replacement_image.mView || + old_image.mMemory == replacement_image.mMemory || + (mHasOutput && (mOutputHandle.mIndex == old_handle.mIndex || + mOutput.mImage == old_image.mImage || + mOutput.mImage == replacement_image.mImage || + mOutput.mView == old_image.mView || + mOutput.mView == replacement_image.mView || + mOutput.mMemory == old_image.mMemory || + mOutput.mMemory == replacement_image.mMemory))) + { + return false; + } + mOldHandle = old_handle; + mOldImage = std::move(old_image); + mReplacementHandle = replacement_handle; + mReplacementImage = std::move(replacement_image); + mHasImageGenerations = true; + return true; +} + +bool Registry::addOutput(LLRenderContract::ImageHandle handle, ImageBinding output) +{ + if (!handle || mHasOutput || !completeImage(output) || + output.mHasPreExecutionMipSnapshot || + (mHasImageGenerations && + (handle.mIndex == mOldHandle.mIndex || output.mImage == mOldImage.mImage || + output.mImage == mReplacementImage.mImage || output.mView == mOldImage.mView || + output.mView == mReplacementImage.mView || output.mMemory == mOldImage.mMemory || + output.mMemory == mReplacementImage.mMemory))) + { + return false; + } + mOutputHandle = handle; + mOutput = std::move(output); + mHasOutput = true; + return true; +} + +bool Registry::addSampler(LLRenderContract::SamplerHandle handle, + SamplerBinding sampler) +{ + if (!handle || mHasSampler || sampler.mSampler == VK_NULL_HANDLE || + sampler.mOwnershipToken == 0) + { + return false; + } + mSamplerHandle = handle; + mSampler = std::move(sampler); + mHasSampler = true; + return true; +} + +bool Registry::addPipeline(LLRenderContract::PipelineHandle handle, + PipelineBinding pipeline) +{ + if (!handle || mHasPipeline || !completePipeline(pipeline)) + { + return false; + } + mPipelineHandle = handle; + mPipeline = std::move(pipeline); + mHasPipeline = true; + return true; +} + +bool Registry::addTransferResources(TransferResources resources) +{ + if (mHasTransferResources || !completeBuffer(resources.mStaging) || + !completeBuffer(resources.mReadback) || + resources.mStaging.mBuffer == resources.mReadback.mBuffer || + resources.mStaging.mMemory == resources.mReadback.mMemory || + resources.mStaging.mMapped == resources.mReadback.mMapped || + (mHasScreenTriangle && + (resources.mStaging.mBuffer == mScreenTriangle.mBuffer || + resources.mReadback.mBuffer == mScreenTriangle.mBuffer || + resources.mStaging.mMemory == mScreenTriangle.mMemory || + resources.mReadback.mMemory == mScreenTriangle.mMemory))) + { + return false; + } + mTransferResources = std::move(resources); + mHasTransferResources = true; + return true; +} + +bool Registry::addLifecycle(LifecycleLedger* ledger) +{ + if (!ledger || mLifecycle || !pristineLifecycle(*ledger)) + { + return false; + } + mLifecycle = ledger; + return true; +} + +const BufferBinding* Registry::resolve(LLRenderContract::BufferHandle handle) const +{ + return mHasScreenTriangle && handle == mScreenHandle ? &mScreenTriangle : nullptr; +} + +const ImageBinding* Registry::resolveRegisteredImage( + LLRenderContract::ImageHandle handle) const +{ + if (!mHasImageGenerations) + { + return nullptr; + } + if (handle == mOldHandle) + { + return &mOldImage; + } + return handle == mReplacementHandle ? &mReplacementImage : nullptr; +} + +const ImageBinding* Registry::resolveOutput(LLRenderContract::ImageHandle handle) const +{ + return mHasOutput && handle == mOutputHandle ? &mOutput : nullptr; +} + +const SamplerBinding* Registry::resolve(LLRenderContract::SamplerHandle handle) const +{ + return mHasSampler && handle == mSamplerHandle ? &mSampler : nullptr; +} + +const PipelineBinding* Registry::resolve( + LLRenderContract::PipelineHandle handle, + const LLRenderContract::ShaderProgramKey& program) const +{ + return mHasPipeline && handle == mPipelineHandle && + program.mName == mPipeline.mProgram.mName && + program.mVariant == mPipeline.mProgram.mVariant + ? &mPipeline + : nullptr; +} + +const TransferResources* Registry::transferResources() const +{ + return mHasTransferResources ? &mTransferResources : nullptr; +} + +LifecycleLedger* Registry::lifecycle() const +{ + return mLifecycle; +} + +bool Registry::isResolvable(LLRenderContract::ImageHandle handle) const +{ + return mLifecycle && mLifecycle->mCurrentImage == handle && + resolveRegisteredImage(handle) != nullptr; +} + +bool execute(const LLRenderContract::FrameSnapshot& frame, Registry& registry, + const ExecutionContext& context, ExecutionResult& result, + std::string* error) +{ + std::string local_error; + if (error) + { + error->clear(); + } + const auto fail = [error, &local_error](const char* fallback) + { + if (local_error.empty()) + { + local_error = fallback; + } + if (error) + { + *error = local_error; + } + return false; + }; + + const auto prepared = prepare(frame, registry, context, local_error); + if (!prepared) + { + return fail("packet, registry, or context preflight failed"); + } + + const BufferBinding& staging = prepared->mTransfer->mStaging; + const BufferBinding& readback = prepared->mTransfer->mReadback; + // From here onward failures may leave private scratch or GPU resources + // changed, but the borrowed lifecycle and caller artifact remain + // unpublished. Vulkan failure/device-loss paths are not rollback-safe. + std::memcpy(staging.mMapped, prepared->mInputs.mPixels.data(), STAGING_BYTE_SIZE); + if (!mappedRangeOperation(context.mDevice, staging, true, local_error)) + { + return fail("staging flush failed"); + } + + ++*context.mRecordingAttemptCount; + VkResult vk_result = vkResetCommandBuffer(context.mCommandBuffer, 0); + if (vk_result != VK_SUCCESS) + { + local_error = "vkResetCommandBuffer failed"; + return fail("command reset failed"); + } + VkCommandBufferBeginInfo begin{}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + vk_result = vkBeginCommandBuffer(context.mCommandBuffer, &begin); + if (vk_result != VK_SUCCESS) + { + local_error = "vkBeginCommandBuffer failed"; + return fail("command begin failed"); + } + + const VkBufferMemoryBarrier staging_barrier = + bufferBarrier(staging, VK_ACCESS_HOST_WRITE_BIT, VK_ACCESS_TRANSFER_READ_BIT); + vkCmdPipelineBarrier(context.mCommandBuffer, VK_PIPELINE_STAGE_HOST_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 1, + &staging_barrier, 0, nullptr); + + const VkImageMemoryBarrier replacement_destination = imageBarrier( + *prepared->mReplacement, VK_IMAGE_LAYOUT_UNDEFINED, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 0, VK_ACCESS_TRANSFER_WRITE_BIT, + 0, LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS); + vkCmdPipelineBarrier(context.mCommandBuffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, + 1, &replacement_destination); + + const VkImageMemoryBarrier output_attachment = imageBarrier( + *prepared->mOutput, VK_IMAGE_LAYOUT_UNDEFINED, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, 0, + VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, 0, 1); + vkCmdPipelineBarrier(context.mCommandBuffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, 0, 0, nullptr, + 0, nullptr, 1, &output_attachment); + + std::array + upload_rows{}; + for (std::uint32_t row = 0; + row < LLRenderContract::TEXTURE_UPLOAD_RESIDENT_HEIGHT; ++row) + { + upload_rows[row] = imageCopy(UPLOAD_ROW_SOURCE_OFFSETS[row], 0, + LLRenderContract::TEXTURE_UPLOAD_RESIDENT_WIDTH, + 1, row); + } + vkCmdCopyBufferToImage(context.mCommandBuffer, staging.mBuffer, + prepared->mReplacement->mImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + static_cast(upload_rows.size()), + upload_rows.data()); + + const VkImageMemoryBarrier mip_zero_source = imageBarrier( + *prepared->mReplacement, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_TRANSFER_WRITE_BIT, + VK_ACCESS_TRANSFER_READ_BIT, 0, 1); + vkCmdPipelineBarrier(context.mCommandBuffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, + 1, &mip_zero_source); + const VkImageBlit first_blit = mipBlit(0, 8, 4, 4, 2); + vkCmdBlitImage(context.mCommandBuffer, prepared->mReplacement->mImage, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + prepared->mReplacement->mImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &first_blit, + VK_FILTER_LINEAR); + + const VkImageMemoryBarrier mip_one_source = imageBarrier( + *prepared->mReplacement, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_TRANSFER_WRITE_BIT, + VK_ACCESS_TRANSFER_READ_BIT, 1, 1); + vkCmdPipelineBarrier(context.mCommandBuffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, + 1, &mip_one_source); + const VkImageBlit second_blit = mipBlit(1, 4, 2, 2, 1); + vkCmdBlitImage(context.mCommandBuffer, prepared->mReplacement->mImage, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + prepared->mReplacement->mImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &second_blit, + VK_FILTER_LINEAR); + + std::array sampled_barriers{ + imageBarrier(*prepared->mReplacement, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_SHADER_READ_BIT, 0, 2), + imageBarrier(*prepared->mReplacement, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, 2, 1) + }; + vkCmdPipelineBarrier(context.mCommandBuffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, 0, nullptr, 0, + nullptr, static_cast(sampled_barriers.size()), + sampled_barriers.data()); + + VkRenderPassBeginInfo render_pass{}; + render_pass.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + render_pass.renderPass = prepared->mPipeline->mRenderPass; + render_pass.framebuffer = prepared->mPipeline->mFramebuffer; + render_pass.renderArea = { { 0, 0 }, + { LLRenderContract::TEXTURE_UPLOAD_OUTPUT_WIDTH, + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_HEIGHT } }; + render_pass.clearValueCount = 0; + render_pass.pClearValues = nullptr; + vkCmdBeginRenderPass(context.mCommandBuffer, &render_pass, + VK_SUBPASS_CONTENTS_INLINE); + + VkViewport viewport{ + 0.f, static_cast(LLRenderContract::TEXTURE_UPLOAD_OUTPUT_HEIGHT), + static_cast(LLRenderContract::TEXTURE_UPLOAD_OUTPUT_WIDTH), + -static_cast(LLRenderContract::TEXTURE_UPLOAD_OUTPUT_HEIGHT), 0.f, 1.f + }; + VkRect2D scissor{ + { 0, 0 }, + { LLRenderContract::TEXTURE_UPLOAD_OUTPUT_WIDTH, + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_HEIGHT } + }; + vkCmdSetViewport(context.mCommandBuffer, 0, 1, &viewport); + vkCmdSetScissor(context.mCommandBuffer, 0, 1, &scissor); + vkCmdBindPipeline(context.mCommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, + prepared->mPipeline->mPipeline); + vkCmdBindDescriptorSets(context.mCommandBuffer, + VK_PIPELINE_BIND_POINT_GRAPHICS, + prepared->mPipeline->mLayout, 0, 1, + &prepared->mPipeline->mDescriptorSet, 0, nullptr); + const VkBuffer vertex_buffer = prepared->mScreen->mBuffer; + constexpr VkDeviceSize vertex_offset = 0; + vkCmdBindVertexBuffers(context.mCommandBuffer, 0, 1, &vertex_buffer, + &vertex_offset); + vkCmdDraw(context.mCommandBuffer, 3, 1, 0, 0); + vkCmdEndRenderPass(context.mCommandBuffer); + + std::array readback_sources{ + imageBarrier(*prepared->mReplacement, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_ACCESS_SHADER_READ_BIT, VK_ACCESS_TRANSFER_READ_BIT, + 0, LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS), + imageBarrier(*prepared->mOutput, + VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT, + VK_ACCESS_TRANSFER_READ_BIT, 0, 1) + }; + vkCmdPipelineBarrier(context.mCommandBuffer, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, + static_cast(readback_sources.size()), + readback_sources.data()); + + std::array + mip_copies{}; + std::uint32_t mip_width = LLRenderContract::TEXTURE_UPLOAD_RESIDENT_WIDTH; + std::uint32_t mip_height = LLRenderContract::TEXTURE_UPLOAD_RESIDENT_HEIGHT; + for (std::uint32_t mip = 0; mip < LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS; + ++mip) + { + mip_copies[mip] = imageCopy( + LLRenderContract::TEXTURE_UPLOAD_MIP_BYTE_OFFSETS[mip], mip, + mip_width, mip_height); + mip_width = std::max(1u, mip_width / 2); + mip_height = std::max(1u, mip_height / 2); + } + vkCmdCopyImageToBuffer(context.mCommandBuffer, + prepared->mReplacement->mImage, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readback.mBuffer, + static_cast(mip_copies.size()), + mip_copies.data()); + const VkBufferImageCopy output_copy = imageCopy( + OUTPUT_READBACK_BYTE_OFFSET, 0, + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_WIDTH, + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_HEIGHT); + vkCmdCopyImageToBuffer(context.mCommandBuffer, prepared->mOutput->mImage, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, readback.mBuffer, + 1, &output_copy); + + std::array final_images{ + imageBarrier(*prepared->mReplacement, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_SHADER_READ_BIT, + 0, LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS), + imageBarrier(*prepared->mOutput, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_READ_BIT, VK_ACCESS_SHADER_READ_BIT, 0, 1) + }; + const VkBufferMemoryBarrier host_readback = bufferBarrier( + readback, VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_HOST_READ_BIT); + vkCmdPipelineBarrier(context.mCommandBuffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | + VK_PIPELINE_STAGE_HOST_BIT, + 0, 0, nullptr, 1, &host_readback, + static_cast(final_images.size()), + final_images.data()); + + vk_result = vkEndCommandBuffer(context.mCommandBuffer); + if (vk_result != VK_SUCCESS) + { + local_error = "vkEndCommandBuffer failed"; + return fail("command end failed"); + } + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &context.mCommandBuffer; + vk_result = vkQueueSubmit(context.mQueue, 1, &submit, VK_NULL_HANDLE); + if (vk_result != VK_SUCCESS) + { + local_error = "vkQueueSubmit failed"; + return fail("queue submission failed"); + } + ++*context.mSubmissionCount; + vk_result = vkQueueWaitIdle(context.mQueue); + if (vk_result != VK_SUCCESS) + { + local_error = "vkQueueWaitIdle failed"; + return fail("queue completion failed"); + } + if (!mappedRangeOperation(context.mDevice, readback, false, local_error)) + { + return fail("readback invalidation failed"); + } + + const auto* bytes = static_cast(readback.mMapped); + ExecutionResult completed = LLRenderContract::makeTextureUploadArtifact(); + completed.mPriorRevision = prepared->mLifecycle->mLastRevision; + completed.mRevision = prepared->mInputs.mRevision; + for (std::size_t mip = 0; mip < LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS; ++mip) + { + const std::size_t offset = LLRenderContract::TEXTURE_UPLOAD_MIP_BYTE_OFFSETS[mip]; + const std::size_t size = LLRenderContract::TEXTURE_UPLOAD_MIP_BYTE_SIZES[mip]; + completed.mMipRGBA8[mip].assign(bytes + offset, bytes + offset + size); + } + + constexpr std::size_t output_row_size = + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_WIDTH * + LLRenderContract::TEXTURE_UPLOAD_CHANNELS; + completed.mSampledRGBA8.resize(LLRenderContract::TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT); + for (std::size_t destination_row = 0; + destination_row < LLRenderContract::TEXTURE_UPLOAD_OUTPUT_HEIGHT; + ++destination_row) + { + const std::size_t source_row = + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_HEIGHT - 1 - destination_row; + std::memcpy(completed.mSampledRGBA8.data() + destination_row * output_row_size, + bytes + OUTPUT_READBACK_BYTE_OFFSET + source_row * output_row_size, + output_row_size); + } + + LifecycleLedger next = *prepared->mLifecycle; + next.mCurrentImage = prepared->mInputs.mHandles.mReplacementImage; + next.mLastRevision = prepared->mInputs.mRevision; + next.mCompletionPending = false; + ++next.mCompletionCount; + next.mCompletedDestination = prepared->mInputs.mHandles.mReplacementImage; + next.mCompletedRevision = prepared->mInputs.mRevision; + next.mCompletedFrame = prepared->mInputs.mFrame; + ++next.mRetirementCount; + next.mRetiredResource = prepared->mInputs.mHandles.mOldImage; + next.mRetirementFrame = prepared->mInputs.mFrame; + + completed.mCompletionCount = next.mCompletionCount; + completed.mCompletedDestination = next.mCompletedDestination; + completed.mCompletedRevision = next.mCompletedRevision; + completed.mCompletedFrame = next.mCompletedFrame; + completed.mRetirementCount = next.mRetirementCount; + completed.mRetiredResource = next.mRetiredResource; + completed.mRetirementFrame = next.mRetirementFrame; + completed.mOldResolvableBefore = true; + completed.mOldResolvableAfter = false; + completed.mReplacementResolvableAfter = true; + + if (!LLRenderContract::validateTextureUploadArtifact(completed, &local_error) || + !validateContent(*prepared, completed)) + { + if (local_error.empty()) + { + local_error = "exact upload, mip, or sample content validation failed"; + } + return fail("artifact validation failed"); + } + + // Publication is the only logical ownership mutation and follows command + // completion, host visibility, exact readback validation, and artifact + // validation. Preflight validated a queue-idle old-image snapshot and + // disjoint image, view, and memory identities. The sole recorded command + // never names that old generation, which proves its snapshot remains + // unchanged without expanding the fixed 200-byte readback. + *prepared->mLifecycle = next; + result = std::move(completed); + if (error) + { + error->clear(); + } + return true; +} + +} // namespace LLRenderVulkanTextureUpload diff --git a/indra/llrender/vulkan/llrendervulkantextureupload.h b/indra/llrender/vulkan/llrendervulkantextureupload.h new file mode 100644 index 00000000000..bd33bc1e643 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkantextureupload.h @@ -0,0 +1,336 @@ +/** + * @file llrendervulkantextureupload.h + * @brief Narrow Vulkan registry and executor for one streamed texture upload. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANTEXTUREUPLOAD_H +#define LL_LLRENDERVULKANTEXTUREUPLOAD_H + +#include "lltextureuploaddiagnostic.h" + +#include + +#include +#include +#include +#include + +namespace LLRenderVulkanTextureUpload +{ + +inline constexpr std::size_t SCREEN_TRIANGLE_BYTE_SIZE = 48; +inline constexpr std::size_t STAGING_BYTE_SIZE = LLRenderContract::TEXTURE_UPLOAD_SOURCE_BYTE_COUNT; +inline constexpr std::size_t READBACK_BYTE_SIZE = + LLRenderContract::TEXTURE_UPLOAD_MIP_BYTE_COUNT + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT; +inline constexpr std::size_t OUTPUT_READBACK_BYTE_OFFSET = LLRenderContract::TEXTURE_UPLOAD_MIP_BYTE_COUNT; + +// A TopLeft, 36-byte-pitch packet is copied one tight row at a time into a +// BottomLeft Vulkan image. The four poison words never enter the image. +inline constexpr std::array + UPLOAD_ROW_SOURCE_OFFSETS{ 108, 72, 36, 0 }; + +// SHA-256 of the validated SPIR-V module bytes. +using ShaderIdentityToken = std::array; + +// Opaque, nonzero identity assigned by the standalone owner. Every native +// object participating in one execution must carry the device's same token. +using NativeOwnershipToken = std::uint64_t; + +// mMapped addresses byte zero of mMemory and remains valid through execute(). +// Each diagnostic allocation is dedicated, bound at offset zero, and mapped +// over the whole allocation when host access is required. +struct BufferBinding +{ + VkBuffer mBuffer = VK_NULL_HANDLE; + VkDeviceMemory mMemory = VK_NULL_HANDLE; + NativeOwnershipToken mOwnershipToken = 0; + void* mMapped = nullptr; + VkDeviceSize mSize = 0; + VkDeviceSize mAllocationSize = 0; + VkDeviceSize mMemoryOffset = 0; + VkBufferCreateFlags mCreateFlags = 0; + VkBufferUsageFlags mUsage = 0; + VkSharingMode mSharingMode = VK_SHARING_MODE_MAX_ENUM; + VkMemoryPropertyFlags mMemoryProperties = 0; + + // Required only for the immutable 48-byte screen-triangle registration. + bool mHasFixtureBytes = false; + std::array mFixtureBytes{}; +}; + +struct ImageBinding +{ + VkImage mImage = VK_NULL_HANDLE; + VkImageView mView = VK_NULL_HANDLE; + VkDeviceMemory mMemory = VK_NULL_HANDLE; + NativeOwnershipToken mOwnershipToken = 0; + VkDeviceSize mAllocationSize = 0; + VkDeviceSize mMemoryOffset = 0; + VkImageCreateFlags mCreateFlags = 0; + VkImageType mImageType = VK_IMAGE_TYPE_MAX_ENUM; + VkFormat mFormat = VK_FORMAT_UNDEFINED; + LLRenderContract::Extent2D mResidentExtent; + LLRenderContract::Extent2D mLogicalExtent; + std::uint32_t mResidentDiscard = 0; + std::uint32_t mMipLevels = 0; + std::uint32_t mArrayLayers = 0; + VkSampleCountFlagBits mSamples = VK_SAMPLE_COUNT_FLAG_BITS_MAX_ENUM; + VkImageTiling mTiling = VK_IMAGE_TILING_MAX_ENUM; + VkImageUsageFlags mUsage = 0; + VkSharingMode mSharingMode = VK_SHARING_MODE_MAX_ENUM; + VkImageAspectFlags mAspect = 0; + VkImageLayout mLayout = VK_IMAGE_LAYOUT_UNDEFINED; + VkImageViewType mViewType = VK_IMAGE_VIEW_TYPE_MAX_ENUM; + VkFormat mViewFormat = VK_FORMAT_UNDEFINED; + VkComponentMapping mViewComponents{}; + VkImageSubresourceRange mViewRange{}; + + // Set only on the old generation. These are the bytes from a completed, + // queue-idle Vulkan readback immediately before executor registration. + bool mHasPreExecutionMipSnapshot = false; + std::array + mPreExecutionMipRGBA8{}; +}; + +struct SamplerBinding +{ + VkSampler mSampler = VK_NULL_HANDLE; + NativeOwnershipToken mOwnershipToken = 0; + VkSamplerCreateFlags mCreateFlags = 0; + VkFilter mMinFilter = VK_FILTER_NEAREST; + VkFilter mMagFilter = VK_FILTER_NEAREST; + VkSamplerMipmapMode mMipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + VkSamplerAddressMode mAddressU = VK_SAMPLER_ADDRESS_MODE_REPEAT; + VkSamplerAddressMode mAddressV = VK_SAMPLER_ADDRESS_MODE_REPEAT; + VkSamplerAddressMode mAddressW = VK_SAMPLER_ADDRESS_MODE_REPEAT; + float mMipLodBias = 0.f; + VkBool32 mAnisotropyEnable = VK_FALSE; + float mMaxAnisotropy = 1.f; + VkBool32 mCompareEnable = VK_FALSE; + VkCompareOp mCompareOp = VK_COMPARE_OP_ALWAYS; + float mMinLod = 0.f; + float mMaxLod = 0.f; + VkBorderColor mBorderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK; + VkBool32 mUnnormalizedCoordinates = VK_FALSE; +}; + +struct SampledDescriptorBinding +{ + std::uint32_t mSet = 0; + std::uint32_t mBinding = 0; + VkDescriptorType mType = VK_DESCRIPTOR_TYPE_MAX_ENUM; + VkShaderStageFlags mStages = 0; + VkImageView mView = VK_NULL_HANDLE; + VkSampler mSampler = VK_NULL_HANDLE; + VkImageLayout mLayout = VK_IMAGE_LAYOUT_UNDEFINED; +}; + +struct VertexBindingState +{ + std::uint32_t mBinding = 0; + std::uint32_t mStride = 0; + VkVertexInputRate mInputRate = VK_VERTEX_INPUT_RATE_MAX_ENUM; +}; + +struct VertexAttributeState +{ + std::uint32_t mLocation = 0; + std::uint32_t mBinding = 0; + VkFormat mFormat = VK_FORMAT_UNDEFINED; + std::uint32_t mOffset = 0; +}; + +struct RasterState +{ + VkPrimitiveTopology mTopology = VK_PRIMITIVE_TOPOLOGY_MAX_ENUM; + VkBool32 mPrimitiveRestartEnable = VK_FALSE; + VkBool32 mDepthClampEnable = VK_FALSE; + VkBool32 mRasterizerDiscardEnable = VK_FALSE; + VkPolygonMode mPolygonMode = VK_POLYGON_MODE_MAX_ENUM; + VkCullModeFlags mCullMode = VK_CULL_MODE_NONE; + VkFrontFace mFrontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + VkBool32 mDepthBiasEnable = VK_FALSE; + float mDepthBiasConstantFactor = 0.f; + float mDepthBiasClamp = 0.f; + float mDepthBiasSlopeFactor = 0.f; + float mLineWidth = 0.f; +}; + +struct MultisampleState +{ + VkSampleCountFlagBits mRasterizationSamples = VK_SAMPLE_COUNT_FLAG_BITS_MAX_ENUM; + VkBool32 mSampleShadingEnable = VK_FALSE; + float mMinSampleShading = 0.f; + VkSampleMask mSampleMask = 0; + VkBool32 mAlphaToCoverageEnable = VK_FALSE; + VkBool32 mAlphaToOneEnable = VK_FALSE; +}; + +struct DepthStencilState +{ + VkBool32 mDepthTestEnable = VK_FALSE; + VkBool32 mDepthWriteEnable = VK_FALSE; + VkCompareOp mDepthCompareOp = VK_COMPARE_OP_ALWAYS; + VkBool32 mDepthBoundsTestEnable = VK_FALSE; + VkBool32 mStencilTestEnable = VK_FALSE; + VkStencilOpState mFront{}; + VkStencilOpState mBack{}; + float mMinDepthBounds = 0.f; + float mMaxDepthBounds = 1.f; +}; + +struct ColorTargetState +{ + VkFormat mFormat = VK_FORMAT_UNDEFINED; + VkBool32 mBlendEnable = VK_FALSE; + VkBlendFactor mSrcColorBlendFactor = VK_BLEND_FACTOR_ONE; + VkBlendFactor mDstColorBlendFactor = VK_BLEND_FACTOR_ZERO; + VkBlendOp mColorBlendOp = VK_BLEND_OP_ADD; + VkBlendFactor mSrcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + VkBlendFactor mDstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; + VkBlendOp mAlphaBlendOp = VK_BLEND_OP_ADD; + VkColorComponentFlags mWriteMask = 0; +}; + +struct PipelineBinding +{ + LLRenderContract::ShaderProgramKey mProgram; + VkPipeline mPipeline = VK_NULL_HANDLE; + VkPipelineLayout mLayout = VK_NULL_HANDLE; + VkRenderPass mRenderPass = VK_NULL_HANDLE; + VkFramebuffer mFramebuffer = VK_NULL_HANDLE; + VkDescriptorSet mDescriptorSet = VK_NULL_HANDLE; + NativeOwnershipToken mOwnershipToken = 0; + LLRenderContract::Extent2D mExtent; + + SampledDescriptorBinding mSampledDescriptor; + + VkImageView mColorView = VK_NULL_HANDLE; + VkFormat mColorFormat = VK_FORMAT_UNDEFINED; + VkSampleCountFlagBits mColorSamples = VK_SAMPLE_COUNT_FLAG_BITS_MAX_ENUM; + VkAttachmentLoadOp mColorLoadOp = VK_ATTACHMENT_LOAD_OP_MAX_ENUM; + VkAttachmentStoreOp mColorStoreOp = VK_ATTACHMENT_STORE_OP_MAX_ENUM; + VkAttachmentLoadOp mStencilLoadOp = VK_ATTACHMENT_LOAD_OP_MAX_ENUM; + VkAttachmentStoreOp mStencilStoreOp = VK_ATTACHMENT_STORE_OP_MAX_ENUM; + VkImageLayout mColorInitialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + VkImageLayout mColorFinalLayout = VK_IMAGE_LAYOUT_UNDEFINED; + std::uint32_t mSubpassDependencyCount = 0; + + VertexBindingState mVertexBinding; + VertexAttributeState mVertexAttribute; + RasterState mRaster; + MultisampleState mMultisample; + DepthStencilState mDepthStencil; + ColorTargetState mColorTarget; + VkBool32 mLogicOpEnable = VK_FALSE; + VkLogicOp mLogicOp = VK_LOGIC_OP_COPY; + std::array mBlendConstants{}; + VkBool32 mDynamicViewport = VK_FALSE; + VkBool32 mDynamicScissor = VK_FALSE; + + ShaderIdentityToken mVertexShaderIdentity{}; + ShaderIdentityToken mFragmentShaderIdentity{}; + std::string mVertexEntryPoint = "main"; + std::string mFragmentEntryPoint = "main"; +}; + +struct TransferResources +{ + BufferBinding mStaging; + BufferBinding mReadback; +}; + +using LifecycleLedger = LLRenderContract::StreamingUploadLifecycle; +using ExecutionResult = LLRenderContract::TextureUploadArtifact; + +// The registry borrows immutable native bindings and one lifecycle ledger for +// a synchronous diagnostic run. Registration never invokes Vulkan. +class Registry +{ +public: + bool addScreenTriangle(LLRenderContract::BufferHandle handle, BufferBinding binding); + bool addImageGenerations(LLRenderContract::ImageHandle old_handle, ImageBinding old_image, + LLRenderContract::ImageHandle replacement_handle, ImageBinding replacement_image); + bool addOutput(LLRenderContract::ImageHandle handle, ImageBinding output); + bool addSampler(LLRenderContract::SamplerHandle handle, SamplerBinding sampler); + bool addPipeline(LLRenderContract::PipelineHandle handle, PipelineBinding pipeline); + bool addTransferResources(TransferResources resources); + bool addLifecycle(LifecycleLedger* ledger); + + const BufferBinding* resolve(LLRenderContract::BufferHandle handle) const; + const ImageBinding* resolveRegisteredImage(LLRenderContract::ImageHandle handle) const; + const ImageBinding* resolveOutput(LLRenderContract::ImageHandle handle) const; + const SamplerBinding* resolve(LLRenderContract::SamplerHandle handle) const; + const PipelineBinding* resolve(LLRenderContract::PipelineHandle handle, + const LLRenderContract::ShaderProgramKey& program) const; + const TransferResources* transferResources() const; + LifecycleLedger* lifecycle() const; + bool isResolvable(LLRenderContract::ImageHandle handle) const; + +private: + LLRenderContract::BufferHandle mScreenHandle; + BufferBinding mScreenTriangle; + bool mHasScreenTriangle = false; + + LLRenderContract::ImageHandle mOldHandle; + ImageBinding mOldImage; + LLRenderContract::ImageHandle mReplacementHandle; + ImageBinding mReplacementImage; + bool mHasImageGenerations = false; + + LLRenderContract::ImageHandle mOutputHandle; + ImageBinding mOutput; + bool mHasOutput = false; + + LLRenderContract::SamplerHandle mSamplerHandle; + SamplerBinding mSampler; + bool mHasSampler = false; + + LLRenderContract::PipelineHandle mPipelineHandle; + PipelineBinding mPipeline; + bool mHasPipeline = false; + + TransferResources mTransferResources; + bool mHasTransferResources = false; + LifecycleLedger* mLifecycle = nullptr; +}; + +struct ExecutionContext +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkCommandPool mCommandPool = VK_NULL_HANDLE; + VkCommandBuffer mCommandBuffer = VK_NULL_HANDLE; + VkQueue mQueue = VK_NULL_HANDLE; + NativeOwnershipToken mOwnershipToken = 0; + std::uint32_t mQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + VkQueueFlags mQueueFamilyFlags = 0; + std::uint32_t mQueueCount = 0; + std::uint32_t mQueueIndex = 0; + std::uint32_t mCommandPoolQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + VkCommandPoolCreateFlags mCommandPoolFlags = 0; + VkCommandBufferLevel mCommandBufferLevel = VK_COMMAND_BUFFER_LEVEL_MAX_ENUM; + std::uint64_t* mRecordingAttemptCount = nullptr; + std::uint64_t* mSubmissionCount = nullptr; + ShaderIdentityToken mRequiredVertexShaderIdentity{}; + ShaderIdentityToken mRequiredFragmentShaderIdentity{}; +}; + +// All packet, registry, ledger, immutable metadata, and alias checks complete +// before mapped memory, command state, counters, or publication are touched. +bool execute(const LLRenderContract::FrameSnapshot& frame, Registry& registry, + const ExecutionContext& context, ExecutionResult& result, + std::string* error = nullptr); + +} // namespace LLRenderVulkanTextureUpload + +#endif // LL_LLRENDERVULKANTEXTUREUPLOAD_H diff --git a/indra/llrender/vulkan/llrendervulkantonemap.cpp b/indra/llrender/vulkan/llrendervulkantonemap.cpp new file mode 100644 index 00000000000..60698b0d392 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkantonemap.cpp @@ -0,0 +1,340 @@ +/** + * @file llrendervulkantonemap.cpp + * @brief Vulkan replay of the canonical tonemap packet. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkantonemap.h" + +#include +#include +#include +#include + +namespace LLRenderVulkanTonemap +{ +namespace +{ + +template +bool hasIndex(const std::vector& entries, Handle handle) +{ + return std::any_of(entries.begin(), entries.end(), + [handle](const Entry& entry) { return entry.mHandle.mIndex == handle.mIndex; }); +} + +template +bool addEntry(std::vector& entries, Handle handle, Binding binding, bool complete) +{ + if (!handle || !complete || hasIndex(entries, handle)) + { + return false; + } + entries.push_back({ handle, std::move(binding) }); + return true; +} + +template +const auto* resolveEntry(const std::vector& entries, Handle handle) +{ + const auto found = std::find_if(entries.begin(), entries.end(), + [handle](const Entry& entry) { return entry.mHandle == handle; }); + return found == entries.end() ? nullptr : &found->mBinding; +} + +bool sameExtent(LLRenderContract::Extent2D left, LLRenderContract::Extent2D right) +{ + return left.mWidth == right.mWidth && left.mHeight == right.mHeight; +} + +std::optional vkFormat(LLRenderContract::PixelFormat format) +{ + switch (format) + { + case LLRenderContract::PixelFormat::RGBA8Unorm: + return VK_FORMAT_R8G8B8A8_UNORM; + case LLRenderContract::PixelFormat::R16Float: + return VK_FORMAT_R16_SFLOAT; + case LLRenderContract::PixelFormat::RGBA16Float: + return VK_FORMAT_R16G16B16A16_SFLOAT; + default: + return std::nullopt; + } +} + +struct Prepared +{ + LLRenderContract::TonemapInputs mInputs; + const BufferBinding* mTriangle = nullptr; + const ImageBinding* mScene = nullptr; + const ImageBinding* mExposure = nullptr; + const ImageBinding* mDestination = nullptr; + const PipelineBinding* mPipeline = nullptr; + const LLRenderContract::Draw* mDraw = nullptr; +}; + +std::optional prepare(const LLRenderContract::FrameSnapshot& frame, + const Registry& registry, const ExecutionContext& context, + std::string& error) +{ + const auto inputs = LLRenderContract::decodeTonemapFrame(frame); + if (!inputs) + { + error = "packet is not the canonical tonemap frame"; + return std::nullopt; + } + if (context.mDevice == VK_NULL_HANDLE || context.mCommandBuffer == VK_NULL_HANDLE || + context.mQueue == VK_NULL_HANDLE || !context.mSubmissionCount) + { + error = "execution context is incomplete"; + return std::nullopt; + } + + Prepared result; + result.mInputs = *inputs; + result.mTriangle = registry.resolve(inputs->mHandles.mScreenTriangle); + result.mScene = registry.resolve(inputs->mHandles.mScene); + result.mExposure = registry.resolve(inputs->mHandles.mExposure); + result.mDestination = registry.resolve(inputs->mHandles.mDestination); + result.mPipeline = registry.resolve(inputs->mHandles.mPipeline, frame.mPipelines.front().mProgram); + result.mDraw = &std::get(frame.mPasses.front().mDraws.front()); + + const SamplerBinding* point = registry.resolve(inputs->mHandles.mPointSampler); + const SamplerBinding* linear = registry.resolve(inputs->mHandles.mLinearSampler); + if (!result.mTriangle || !result.mScene || !result.mExposure || !result.mDestination || + !result.mPipeline || !point || !linear) + { + error = "registry cannot resolve an exact live resource generation"; + return std::nullopt; + } + + const auto destination_format = vkFormat(inputs->mDestinationFormat); + if (!destination_format || result.mTriangle->mSize < 48 || + (result.mTriangle->mUsage & VK_BUFFER_USAGE_VERTEX_BUFFER_BIT) == 0 || + result.mScene->mFormat != VK_FORMAT_R16G16B16A16_SFLOAT || + !sameExtent(result.mScene->mExtent, inputs->mSourceExtent) || + (result.mScene->mUsage & VK_IMAGE_USAGE_SAMPLED_BIT) == 0 || + result.mExposure->mFormat != VK_FORMAT_R16_SFLOAT || + !sameExtent(result.mExposure->mExtent, { 1, 1 }) || + (result.mExposure->mUsage & VK_IMAGE_USAGE_SAMPLED_BIT) == 0 || + result.mDestination->mFormat != *destination_format || + !sameExtent(result.mDestination->mExtent, inputs->mDestinationExtent) || + (result.mDestination->mUsage & + (VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT)) != + (VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT) || + result.mScene->mImage == result.mDestination->mImage || + result.mExposure->mImage == result.mDestination->mImage) + { + error = "registry image or buffer metadata does not match the packet"; + return std::nullopt; + } + + if (point->mMinFilter != LLRenderContract::Filter::Nearest || + point->mMagFilter != LLRenderContract::Filter::Nearest || + point->mAddressU != LLRenderContract::AddressMode::Mirror || + point->mAddressV != LLRenderContract::AddressMode::Mirror || + point->mMipFilter != LLRenderContract::MipFilter::Disabled || + point->mMaxAnisotropy != 1.f || point->mAnisotropyEnabled || + linear->mMinFilter != LLRenderContract::Filter::Linear || + linear->mMagFilter != LLRenderContract::Filter::Linear || + linear->mAddressU != LLRenderContract::AddressMode::Mirror || + linear->mAddressV != LLRenderContract::AddressMode::Mirror || + linear->mMipFilter != LLRenderContract::MipFilter::Disabled || + linear->mMaxAnisotropy != 1.f || linear->mAnisotropyEnabled) + { + error = "registry sampler metadata does not match the packet"; + return std::nullopt; + } + + const PipelineBinding& pipeline = *result.mPipeline; + if (pipeline.mDestinationFormat != inputs->mDestinationFormat || + !sameExtent(pipeline.mExtent, inputs->mDestinationExtent) || + pipeline.mSceneView != result.mScene->mView || pipeline.mExposureView != result.mExposure->mView || + pipeline.mDestinationView != result.mDestination->mView || + pipeline.mPointSampler != point->mSampler || pipeline.mLinearSampler != linear->mSampler || + pipeline.mDestinationFinalLayout != VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL || + pipeline.mDescriptorBindings != std::vector{ 0, 1 } || + pipeline.mVertexStride != 16 || pipeline.mPositionFormat != VK_FORMAT_R32G32B32_SFLOAT || + pipeline.mPositionOffset != 0 || + pipeline.mPushConstantSize != sizeof(LLRenderContract::TonemapParameters)) + { + error = "pipeline layout metadata does not match the packet"; + return std::nullopt; + } + + return result; +} + +} // namespace + +bool Registry::addBuffer(LLRenderContract::BufferHandle handle, BufferBinding binding) +{ + const bool complete = binding.mBuffer != VK_NULL_HANDLE && binding.mSize != 0; + return addEntry(mBuffers, handle, std::move(binding), complete); +} + +bool Registry::addImage(LLRenderContract::ImageHandle handle, ImageBinding binding) +{ + const bool complete = binding.mImage != VK_NULL_HANDLE && binding.mView != VK_NULL_HANDLE && + binding.mFormat != VK_FORMAT_UNDEFINED && binding.mExtent.mWidth != 0 && + binding.mExtent.mHeight != 0; + return addEntry(mImages, handle, std::move(binding), complete); +} + +bool Registry::addSampler(LLRenderContract::SamplerHandle handle, SamplerBinding binding) +{ + const bool complete = binding.mSampler != VK_NULL_HANDLE; + return addEntry(mSamplers, handle, std::move(binding), complete); +} + +bool Registry::addPipeline(LLRenderContract::PipelineHandle handle, PipelineBinding binding) +{ + const bool complete = !binding.mProgram.mName.empty() && binding.mPipeline != VK_NULL_HANDLE && + binding.mLayout != VK_NULL_HANDLE && binding.mRenderPass != VK_NULL_HANDLE && + binding.mFramebuffer != VK_NULL_HANDLE && binding.mDescriptorSet != VK_NULL_HANDLE; + return addEntry(mPipelines, handle, std::move(binding), complete); +} + +const BufferBinding* Registry::resolve(LLRenderContract::BufferHandle handle) const +{ + return resolveEntry(mBuffers, handle); +} + +const ImageBinding* Registry::resolve(LLRenderContract::ImageHandle handle) const +{ + return resolveEntry(mImages, handle); +} + +const SamplerBinding* Registry::resolve(LLRenderContract::SamplerHandle handle) const +{ + return resolveEntry(mSamplers, handle); +} + +const PipelineBinding* Registry::resolve(LLRenderContract::PipelineHandle handle, + const LLRenderContract::ShaderProgramKey& program) const +{ + const PipelineBinding* binding = resolveEntry(mPipelines, handle); + if (!binding || binding->mProgram.mName != program.mName || binding->mProgram.mVariant != program.mVariant) + { + return nullptr; + } + return binding; +} + +bool execute(const LLRenderContract::FrameSnapshot& frame, const Registry& registry, + const ExecutionContext& context, std::string& error) +{ + const auto prepared = prepare(frame, registry, context, error); + if (!prepared) + { + return false; + } + + VkResult result = vkResetCommandBuffer(context.mCommandBuffer, 0); + if (result != VK_SUCCESS) + { + error = "vkResetCommandBuffer failed"; + return false; + } + + VkCommandBufferBeginInfo begin{}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + result = vkBeginCommandBuffer(context.mCommandBuffer, &begin); + if (result != VK_SUCCESS) + { + error = "vkBeginCommandBuffer failed"; + return false; + } + + VkImageMemoryBarrier barrier{}; + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.srcAccessMask = 0; + barrier.dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = prepared->mDestination->mImage; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.layerCount = 1; + vkCmdPipelineBarrier(context.mCommandBuffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT, 0, 0, nullptr, 0, nullptr, 1, &barrier); + + VkRenderPassBeginInfo render_pass{}; + render_pass.sType = VK_STRUCTURE_TYPE_RENDER_PASS_BEGIN_INFO; + render_pass.renderPass = prepared->mPipeline->mRenderPass; + render_pass.framebuffer = prepared->mPipeline->mFramebuffer; + render_pass.renderArea.extent = { prepared->mInputs.mDestinationExtent.mWidth, + prepared->mInputs.mDestinationExtent.mHeight }; + vkCmdBeginRenderPass(context.mCommandBuffer, &render_pass, VK_SUBPASS_CONTENTS_INLINE); + + const LLRenderContract::Viewport& contract_viewport = frame.mPasses.front().mViewport; + // Contract coordinates are bottom-left. A negative Vulkan viewport height + // preserves that orientation without changing the shared shader math. + VkViewport viewport{ contract_viewport.mX, + static_cast(prepared->mInputs.mDestinationExtent.mHeight) - + contract_viewport.mY, + contract_viewport.mWidth, -contract_viewport.mHeight, + contract_viewport.mMinDepth, contract_viewport.mMaxDepth }; + const LLRenderContract::Scissor& contract_scissor = frame.mPasses.front().mScissor; + VkRect2D scissor{ { static_cast(contract_scissor.mX), + static_cast(prepared->mInputs.mDestinationExtent.mHeight - + contract_scissor.mY - contract_scissor.mHeight) }, + { contract_scissor.mWidth, contract_scissor.mHeight } }; + vkCmdSetViewport(context.mCommandBuffer, 0, 1, &viewport); + vkCmdSetScissor(context.mCommandBuffer, 0, 1, &scissor); + vkCmdBindPipeline(context.mCommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, prepared->mPipeline->mPipeline); + vkCmdBindDescriptorSets(context.mCommandBuffer, VK_PIPELINE_BIND_POINT_GRAPHICS, prepared->mPipeline->mLayout, + 0, 1, &prepared->mPipeline->mDescriptorSet, 0, nullptr); + + const LLRenderContract::ByteRange& parameters = prepared->mDraw->mResources.mParameters.front().mBytes; + vkCmdPushConstants(context.mCommandBuffer, prepared->mPipeline->mLayout, VK_SHADER_STAGE_FRAGMENT_BIT, + 0, static_cast(parameters.mSize), + parameters.mStorage->data() + parameters.mOffset); + const VkDeviceSize offset = prepared->mDraw->mResources.mVertexBuffers.front().mOffset; + vkCmdBindVertexBuffers(context.mCommandBuffer, 0, 1, &prepared->mTriangle->mBuffer, &offset); + vkCmdDraw(context.mCommandBuffer, prepared->mDraw->mVertexCount, prepared->mDraw->mInstanceCount, + prepared->mDraw->mFirstVertex, prepared->mDraw->mFirstInstance); + vkCmdEndRenderPass(context.mCommandBuffer); + + result = vkEndCommandBuffer(context.mCommandBuffer); + if (result != VK_SUCCESS) + { + error = "vkEndCommandBuffer failed"; + return false; + } + + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &context.mCommandBuffer; + result = vkQueueSubmit(context.mQueue, 1, &submit, VK_NULL_HANDLE); + if (result != VK_SUCCESS) + { + error = "vkQueueSubmit failed"; + return false; + } + ++*context.mSubmissionCount; + result = vkQueueWaitIdle(context.mQueue); + if (result != VK_SUCCESS) + { + error = "vkQueueWaitIdle failed"; + return false; + } + + error.clear(); + return true; +} + +} // namespace LLRenderVulkanTonemap diff --git a/indra/llrender/vulkan/llrendervulkantonemap.h b/indra/llrender/vulkan/llrendervulkantonemap.h new file mode 100644 index 00000000000..44aaf5f74c4 --- /dev/null +++ b/indra/llrender/vulkan/llrendervulkantonemap.h @@ -0,0 +1,125 @@ +/** + * @file llrendervulkantonemap.h + * @brief Narrow Vulkan registry and executor for tonemap packets. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLRENDERVULKANTONEMAP_H +#define LL_LLRENDERVULKANTONEMAP_H + +#include "lltonemapcontract.h" + +#include + +#include +#include +#include + +namespace LLRenderVulkanTonemap +{ + +struct BufferBinding +{ + VkBuffer mBuffer = VK_NULL_HANDLE; + VkDeviceSize mSize = 0; + VkBufferUsageFlags mUsage = 0; +}; + +struct ImageBinding +{ + VkImage mImage = VK_NULL_HANDLE; + VkImageView mView = VK_NULL_HANDLE; + VkFormat mFormat = VK_FORMAT_UNDEFINED; + LLRenderContract::Extent2D mExtent; + VkImageUsageFlags mUsage = 0; +}; + +struct SamplerBinding +{ + VkSampler mSampler = VK_NULL_HANDLE; + LLRenderContract::Filter mMinFilter = LLRenderContract::Filter::Linear; + LLRenderContract::Filter mMagFilter = LLRenderContract::Filter::Linear; + LLRenderContract::AddressMode mAddressU = LLRenderContract::AddressMode::Clamp; + LLRenderContract::AddressMode mAddressV = LLRenderContract::AddressMode::Clamp; + LLRenderContract::MipFilter mMipFilter = LLRenderContract::MipFilter::Disabled; + float mMaxAnisotropy = 1.f; + bool mAnisotropyEnabled = false; +}; + +struct PipelineBinding +{ + LLRenderContract::ShaderProgramKey mProgram; + LLRenderContract::PixelFormat mDestinationFormat = LLRenderContract::PixelFormat::RGBA8Unorm; + LLRenderContract::Extent2D mExtent; + VkPipeline mPipeline = VK_NULL_HANDLE; + VkPipelineLayout mLayout = VK_NULL_HANDLE; + VkRenderPass mRenderPass = VK_NULL_HANDLE; + VkFramebuffer mFramebuffer = VK_NULL_HANDLE; + VkDescriptorSet mDescriptorSet = VK_NULL_HANDLE; + VkImageView mSceneView = VK_NULL_HANDLE; + VkImageView mExposureView = VK_NULL_HANDLE; + VkImageView mDestinationView = VK_NULL_HANDLE; + VkSampler mPointSampler = VK_NULL_HANDLE; + VkSampler mLinearSampler = VK_NULL_HANDLE; + VkImageLayout mDestinationFinalLayout = VK_IMAGE_LAYOUT_UNDEFINED; + std::vector mDescriptorBindings; + std::uint32_t mVertexStride = 0; + VkFormat mPositionFormat = VK_FORMAT_UNDEFINED; + std::uint32_t mPositionOffset = 0; + std::uint32_t mPushConstantSize = 0; +}; + +// The registry borrows objects owned by one synchronous offscreen run. +class Registry +{ +public: + bool addBuffer(LLRenderContract::BufferHandle handle, BufferBinding binding); + bool addImage(LLRenderContract::ImageHandle handle, ImageBinding binding); + bool addSampler(LLRenderContract::SamplerHandle handle, SamplerBinding binding); + bool addPipeline(LLRenderContract::PipelineHandle handle, PipelineBinding binding); + + const BufferBinding* resolve(LLRenderContract::BufferHandle handle) const; + const ImageBinding* resolve(LLRenderContract::ImageHandle handle) const; + const SamplerBinding* resolve(LLRenderContract::SamplerHandle handle) const; + const PipelineBinding* resolve(LLRenderContract::PipelineHandle handle, + const LLRenderContract::ShaderProgramKey& program) const; + +private: + template + struct Entry + { + HandleType mHandle; + BindingType mBinding; + }; + + std::vector> mBuffers; + std::vector> mImages; + std::vector> mSamplers; + std::vector> mPipelines; +}; + +struct ExecutionContext +{ + VkDevice mDevice = VK_NULL_HANDLE; + VkCommandBuffer mCommandBuffer = VK_NULL_HANDLE; + VkQueue mQueue = VK_NULL_HANDLE; + std::uint64_t* mSubmissionCount = nullptr; +}; + +// Performs all packet, handle, and physical-resource checks before beginning +// the command buffer or changing the destination image. +bool execute(const LLRenderContract::FrameSnapshot& frame, const Registry& registry, + const ExecutionContext& context, std::string& error); + +} // namespace LLRenderVulkanTonemap + +#endif // LL_LLRENDERVULKANTONEMAP_H diff --git a/indra/llrender/vulkan/llshadermanifest_dump.cpp b/indra/llrender/vulkan/llshadermanifest_dump.cpp new file mode 100644 index 00000000000..85e307febcb --- /dev/null +++ b/indra/llrender/vulkan/llshadermanifest_dump.cpp @@ -0,0 +1,411 @@ +/** + * @file llshadermanifest_dump.cpp + * @brief Emit the manifest-derived Vulkan material reflection expectation. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llshadermanifest.h" + +#include +#include +#include +#include +#include +#include + +namespace +{ +using namespace LLRenderContract; + +enum class MaterialProfile +{ + Diagnostic, + Production +}; + +std::optional materialProfile(std::string_view name) +{ + if (name == "diagnostic") + { + return MaterialProfile::Diagnostic; + } + if (name == "production") + { + return MaterialProfile::Production; + } + return std::nullopt; +} + +LegacyNormSpecPipelineKey pipelineKey(MaterialProfile profile) +{ + switch (profile) + { + case MaterialProfile::Diagnostic: + return legacyNormSpecDiagnosticPipelineKey(); + case MaterialProfile::Production: + return legacyNormSpecModernHDRPipelineKey(); + } + throw std::invalid_argument("unknown material profile"); +} + +bool validProfileManifest(MaterialProfile profile, const ShaderManifest& manifest) noexcept +{ + switch (profile) + { + case MaterialProfile::Diagnostic: + return validLegacyNormSpecDiagnosticShaderManifest(manifest); + case MaterialProfile::Production: + return validLegacyNormSpecProductionShaderManifest(manifest); + } + return false; +} + +void writeJsonString(std::string_view value) +{ + constexpr char HEX[] = "0123456789abcdef"; + + std::cout.put('"'); + for (const unsigned char character : value) + { + switch (character) + { + case '"': + std::cout << "\\\""; + break; + case '\\': + std::cout << "\\\\"; + break; + case '\b': + std::cout << "\\b"; + break; + case '\f': + std::cout << "\\f"; + break; + case '\n': + std::cout << "\\n"; + break; + case '\r': + std::cout << "\\r"; + break; + case '\t': + std::cout << "\\t"; + break; + default: + if (character < 0x20) + { + std::cout << "\\u00" << HEX[character >> 4] << HEX[character & 0x0f]; + } + else + { + std::cout.put(static_cast(character)); + } + } + } + std::cout.put('"'); +} + +std::string_view moduleName(ShaderStage stage) +{ + switch (stage) + { + case ShaderStage::Vertex: + return "vertex"; + case ShaderStage::Fragment: + return "fragment"; + } + throw std::invalid_argument("unknown shader stage"); +} + +std::string_view reflectionStage(ShaderStage stage) +{ + switch (stage) + { + case ShaderStage::Vertex: + return "vert"; + case ShaderStage::Fragment: + return "frag"; + } + throw std::invalid_argument("unknown shader stage"); +} + +std::string_view valueType(ShaderValueType type) +{ + switch (type) + { + case ShaderValueType::Float: + return "float"; + case ShaderValueType::Float2: + return "vec2"; + case ShaderValueType::Float3: + return "vec3"; + case ShaderValueType::Float4: + return "vec4"; + } + throw std::invalid_argument("unknown shader value type"); +} + +std::string_view vertexValueType(VertexFormat format) +{ + switch (format) + { + case VertexFormat::Float2: + return "vec2"; + case VertexFormat::Float3: + return "vec3"; + case VertexFormat::Float4: + case VertexFormat::UNorm8x4: + return "vec4"; + } + throw std::invalid_argument("unknown vertex format"); +} + +std::string_view sampledImageType(ShaderImageDimension dimension) +{ + switch (dimension) + { + case ShaderImageDimension::TwoD: + return "sampler2D"; + } + throw std::invalid_argument("unknown sampled-image dimension"); +} + +void writeStages(ShaderStageVisibility visibility) +{ + std::cout.put('['); + bool needs_comma = false; + if (visibility.mVertex) + { + writeJsonString("vertex"); + needs_comma = true; + } + if (visibility.mFragment) + { + if (needs_comma) + { + std::cout.put(','); + } + writeJsonString("fragment"); + } + std::cout.put(']'); +} + +template +void writeArray(const Range& range, Writer writer) +{ + std::cout.put('['); + bool needs_comma = false; + for (const auto& item : range) + { + if (needs_comma) + { + std::cout.put(','); + } + writer(item); + needs_comma = true; + } + std::cout.put(']'); +} + +void writeEntryPoints(const ShaderManifest& manifest) +{ + writeArray(manifest.mEntryPoints, + [](const ShaderEntryPoint& entry) + { + std::cout << "{\"module\":"; + writeJsonString(moduleName(entry.mStage)); + std::cout << ",\"name\":"; + writeJsonString(entry.mName); + std::cout << ",\"stage\":"; + writeJsonString(reflectionStage(entry.mStage)); + std::cout.put('}'); + }); +} + +void writeVertexInputs(const ShaderManifest& manifest) +{ + writeArray(manifest.mVertexInputs, + [](const ShaderVertexInput& input) + { + std::cout << "{\"name\":"; + writeJsonString(input.mName); + std::cout << ",\"location\":" << input.mLocation << ",\"type\":"; + writeJsonString(vertexValueType(input.mFormat)); + std::cout.put('}'); + }); +} + +void writeInterstageVariables(const ShaderManifest& manifest) +{ + writeArray(manifest.mInterstageVariables, + [](const ShaderInterstageVariable& variable) + { + std::cout << "{\"name\":"; + writeJsonString(variable.mName); + std::cout << ",\"location\":" << variable.mLocation << ",\"type\":"; + writeJsonString(valueType(variable.mType)); + std::cout.put('}'); + }); +} + +void writeUniformBlocks(const ShaderManifest& manifest) +{ + std::cout.put('['); + if (manifest.mParameterBlock) + { + const ShaderParameterBlock& block = *manifest.mParameterBlock; + std::cout << "{\"name\":"; + writeJsonString(block.mName); + std::cout << ",\"set\":" << block.mSet << ",\"binding\":" << block.mBinding << ",\"size\":" << block.mByteSize << ",\"stages\":"; + writeStages(block.mVisibility); + std::cout.put('}'); + } + std::cout.put(']'); +} + +void writeSampledImages(const ShaderManifest& manifest) +{ + writeArray(manifest.mSampledImages, + [](const ShaderSampledImage& image) + { + std::cout << "{\"name\":"; + writeJsonString(image.mName); + std::cout << ",\"set\":" << image.mSet << ",\"binding\":" << image.mBinding << ",\"type\":"; + writeJsonString(sampledImageType(image.mDimension)); + std::cout << ",\"stages\":"; + writeStages(image.mVisibility); + std::cout.put('}'); + }); +} + +void writeFragmentOutputs(const ShaderManifest& manifest) +{ + std::cout.put('['); + bool needs_comma = false; + for (const ShaderFragmentOutputDeclaration& declaration : manifest.mFragmentOutputDeclarations) + { + for (std::uint32_t offset = 0; offset < declaration.mElementCount; ++offset) + { + if (needs_comma) + { + std::cout.put(','); + } + std::cout << "{\"name\":"; + writeJsonString(declaration.mName); + std::cout << ",\"location\":" << declaration.mFirstLocation + offset << ",\"type\":"; + writeJsonString(valueType(declaration.mElementType)); + std::cout.put('}'); + needs_comma = true; + } + } + std::cout.put(']'); +} + +void writePushConstantRanges(const ShaderManifest& manifest) +{ + writeArray(manifest.mPushConstantRanges, + [](const ShaderPushConstantRange& range) + { + std::cout << "{\"offset\":" << range.mByteOffset << ",\"size\":" << range.mByteSize << ",\"stages\":"; + writeStages(range.mVisibility); + std::cout.put('}'); + }); +} + +void writeFlatInterfaces(const ShaderManifest& manifest) +{ + std::cout.put('['); + bool needs_comma = false; + for (const ShaderInterstageVariable& variable : manifest.mInterstageVariables) + { + if (variable.mInterpolation != ShaderInterpolation::Flat) + { + continue; + } + if (needs_comma) + { + std::cout.put(','); + } + std::cout << "{\"name\":"; + writeJsonString(variable.mName); + std::cout << ",\"location\":" << variable.mLocation << ",\"modules\":[\"vertex\",\"fragment\"]}"; + needs_comma = true; + } + std::cout.put(']'); +} + +void writeExpectation(const ShaderManifest& manifest) +{ + std::cout << "{\"schema\":1,\"entry_points\":"; + writeEntryPoints(manifest); + std::cout << ",\"vertex_inputs\":"; + writeVertexInputs(manifest); + std::cout << ",\"interstage_variables\":"; + writeInterstageVariables(manifest); + std::cout << ",\"uniform_blocks\":"; + writeUniformBlocks(manifest); + std::cout << ",\"combined_image_samplers\":"; + writeSampledImages(manifest); + std::cout << ",\"fragment_outputs\":"; + writeFragmentOutputs(manifest); + std::cout << ",\"push_constant_ranges\":"; + writePushConstantRanges(manifest); + std::cout << ",\"flat_interfaces\":"; + writeFlatInterfaces(manifest); + std::cout << "}\n"; +} + +} // namespace + +int main(int argc, char** argv) +{ + if (argc != 5 || std::string_view(argv[1]) != "--profile" || std::string_view(argv[3]) != "--output") + { + std::cerr << "usage: " << argv[0] << " --profile diagnostic|production --output PATH\n"; + return 2; + } + + const std::optional profile = materialProfile(argv[2]); + if (!profile) + { + std::cerr << "usage: " << argv[0] << " --profile diagnostic|production --output PATH\n"; + return 2; + } + + try + { + const std::optional manifest = + LLRenderContract::legacyNormSpecShaderManifest(pipelineKey(*profile), LLRenderContract::ShaderBackend::Vulkan); + if (!manifest || !validProfileManifest(*profile, *manifest) || manifest->mBackend != LLRenderContract::ShaderBackend::Vulkan) + { + std::cerr << "cannot dump an invalid Vulkan material profile manifest\n"; + return 1; + } + if (std::freopen(argv[4], "wb", stdout) == nullptr) + { + std::cerr << "cannot open shader-manifest output " << argv[4] << '\n'; + return 1; + } + writeExpectation(*manifest); + std::cout.flush(); + if (!std::cout) + { + std::cerr << "cannot write Vulkan material profile manifest\n"; + return 1; + } + } + catch (const std::exception& error) + { + std::cerr << "cannot dump Vulkan material profile manifest: " << error.what() << '\n'; + return 1; + } + return 0; +} diff --git a/indra/llrender/vulkan/lltextureuploadcompare_main.cpp b/indra/llrender/vulkan/lltextureuploadcompare_main.cpp new file mode 100644 index 00000000000..9d64cbdfad0 --- /dev/null +++ b/indra/llrender/vulkan/lltextureuploadcompare_main.cpp @@ -0,0 +1,78 @@ +/** + * @file lltextureuploadcompare_main.cpp + * @brief Exact comparison for fixed-input streamed texture upload artifacts. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "lltextureuploaddiagnostic.h" + +#include +#include +#include + +namespace +{ + +int fail(const std::string& reason, const std::string& detail) +{ + std::cerr << "TEXTURE_UPLOAD_COMPARE result=fail reason=" << reason; + if (!detail.empty()) + { + std::cerr << " detail={" << detail << '}'; + } + std::cerr << '\n'; + return 1; +} + +} // namespace + +int main(int argc, char** argv) +{ + if (argc != 3) + { + std::cerr << "usage: lltextureuploadcompare \n"; + return 2; + } + + LLRenderContract::TextureUploadArtifact reference; + LLRenderContract::TextureUploadArtifact candidate; + std::string error; + if (!LLRenderContract::readTextureUploadArtifact(std::filesystem::path(argv[1]), reference, &error)) + { + return fail("reference_read", error); + } + if (!LLRenderContract::readTextureUploadArtifact(std::filesystem::path(argv[2]), candidate, &error)) + { + return fail("candidate_read", error); + } + + const LLRenderContract::TextureUploadComparisonStats stats = + LLRenderContract::compareTextureUploadArtifacts(reference, candidate); + if (!stats.mComparable) + { + return fail("not_comparable", stats.mError); + } + + std::cout << "TEXTURE_UPLOAD_COMPARE result=" << (stats.mMatch ? "pass" : "fail") + << " mip_bytes=" << stats.mComparedMipBytes + << " sample_bytes=" << stats.mComparedSampleBytes + << " mismatches=" << stats.mMismatchCount; + if (!stats.mMatch) + { + std::cout << " first_plane=" << stats.mFirstMismatchPlane + << " first_byte=" << stats.mFirstMismatchByte + << " reference=" << static_cast(stats.mFirstReference) + << " candidate=" << static_cast(stats.mFirstCandidate); + } + std::cout << '\n'; + return stats.mMatch ? 0 : 1; +} diff --git a/indra/llrender/vulkan/lltonemapcompare_main.cpp b/indra/llrender/vulkan/lltonemapcompare_main.cpp new file mode 100644 index 00000000000..7a29d4c0789 --- /dev/null +++ b/indra/llrender/vulkan/lltonemapcompare_main.cpp @@ -0,0 +1,85 @@ +/** + * @file lltonemapcompare_main.cpp + * @brief Strict comparison for fixed-input tonemap artifacts. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "lltonemapdiagnostic.h" + +#include +#include +#include +#include + +namespace +{ + +int fail(const std::string& reason, const std::string& detail) +{ + std::cerr << "TONEMAP_COMPARE result=fail reason=" << reason; + if (!detail.empty()) + { + std::cerr << " detail={" << detail << '}'; + } + std::cerr << '\n'; + return 1; +} + +} // namespace + +int main(int argc, char** argv) +{ + if (argc != 3) + { + std::cerr << "usage: lltonemapcompare \n"; + return 2; + } + + LLRenderContract::TonemapArtifact reference; + LLRenderContract::TonemapArtifact candidate; + std::string error; + if (!LLRenderContract::readTonemapArtifact(std::filesystem::path(argv[1]), reference, &error)) + { + return fail("reference_read", error); + } + if (!LLRenderContract::readTonemapArtifact(std::filesystem::path(argv[2]), candidate, &error)) + { + return fail("candidate_read", error); + } + + const LLRenderContract::TonemapComparisonStats stats = + LLRenderContract::compareTonemapArtifacts(reference, candidate); + if (!stats.mComparable) + { + return fail("not_comparable", stats.mError); + } + + std::cout << std::setprecision(9) + << "TONEMAP_COMPARE result=" << (stats.mMatch ? "pass" : "fail") + << " cases=" << stats.mComparedCases + << " components=" << stats.mComparedComponents + << " mismatches=" << stats.mMismatchCount + << " max_abs_error=" << stats.mMaximumAbsoluteError + << " rgba8_tolerance=" << LLRenderContract::TONEMAP_RGBA8_TOLERANCE + << " rgba16f_tolerance=" << LLRenderContract::TONEMAP_RGBA16F_TOLERANCE; + if (!stats.mMatch) + { + std::cout << " first_case=" << stats.mFirstMismatchCase + << " first_pixel=" << stats.mFirstMismatchPixel + << " first_channel=" << stats.mFirstMismatchChannel + << " reference=" << stats.mFirstReference + << " candidate=" << stats.mFirstCandidate + << " tolerance=" << stats.mFirstTolerance; + } + std::cout << '\n'; + return stats.mMatch ? 0 : 1; +} diff --git a/indra/llrender/vulkan/llvulkanmaterial_main.cpp b/indra/llrender/vulkan/llvulkanmaterial_main.cpp new file mode 100644 index 00000000000..f1c5b0b5406 --- /dev/null +++ b/indra/llrender/vulkan/llvulkanmaterial_main.cpp @@ -0,0 +1,2765 @@ +/** + * @file llvulkanmaterial_main.cpp + * @brief Offscreen Vulkan replay for the fixed indexed-material diagnostic. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llmaterialdiagnostic.h" +#include "llrendervulkanglobaldispatch.h" +#include "llrendervulkanmaterial.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +using LLRenderContract::Extent2D; +using LLRenderContract::FrameSnapshot; +using LLRenderContract::MaterialArtifact; +using LLRenderContract::MaterialCase; +using LLRenderContract::MaterialFixture; +using LLRenderContract::MaterialParameters; +using LLRenderVulkanMaterial::ShaderIdentityToken; + +constexpr char PORTABILITY_ENUMERATION_EXTENSION[] = "VK_KHR_portability_enumeration"; +constexpr char PORTABILITY_SUBSET_EXTENSION[] = "VK_KHR_portability_subset"; + +constexpr Extent2D FRAME_EXTENT{ LLRenderContract::MATERIAL_FRAME_WIDTH, + LLRenderContract::MATERIAL_FRAME_HEIGHT }; +constexpr Extent2D TEXTURE_EXTENT{ LLRenderContract::MATERIAL_TEXTURE_WIDTH, + LLRenderContract::MATERIAL_TEXTURE_HEIGHT }; +constexpr VkDeviceSize RGBA8_BYTES = + LLRenderContract::MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT * sizeof(std::uint8_t); +constexpr VkDeviceSize RGBA16_BYTES = + LLRenderContract::MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT * sizeof(std::uint16_t); +constexpr VkDeviceSize DEPTH32_BYTES = + LLRenderContract::MATERIAL_DIAGNOSTIC_DEPTH_COMPONENT_COUNT * sizeof(float); +constexpr VkDeviceSize TEXTURE_BYTES = + LLRenderContract::MATERIAL_TEXTURE_COMPONENT_COUNT * sizeof(std::uint8_t); +constexpr VkDeviceSize TRANSFER_BYTES = std::max({ RGBA8_BYTES, RGBA16_BYTES, DEPTH32_BYTES, TEXTURE_BYTES }); + +constexpr std::array VERTEX_STRIDES{ 16, 16, 8, 4, 16, 8, 8 }; +constexpr std::array VERTEX_FORMATS{ + VK_FORMAT_R32G32B32_SFLOAT, + VK_FORMAT_R32G32B32_SFLOAT, + VK_FORMAT_R32G32_SFLOAT, + VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_R32G32B32A32_SFLOAT, + VK_FORMAT_R32G32_SFLOAT, + VK_FORMAT_R32G32_SFLOAT +}; +constexpr std::array VERTEX_OFFSETS{ + LLRenderContract::MATERIAL_POSITION_OFFSET, + LLRenderContract::MATERIAL_NORMAL_OFFSET, + LLRenderContract::MATERIAL_TEXCOORD0_OFFSET, + LLRenderContract::MATERIAL_COLOR_OFFSET, + LLRenderContract::MATERIAL_TANGENT_OFFSET, + LLRenderContract::MATERIAL_TEXCOORD1_OFFSET, + LLRenderContract::MATERIAL_TEXCOORD2_OFFSET +}; + +class Failure : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +class CapabilityFailure : public Failure +{ +public: + using Failure::Failure; +}; + +void check(VkResult result, const char* operation) +{ + if (result != VK_SUCCESS) + { + std::ostringstream message; + message << operation << " failed with VkResult " << result; + throw Failure(message.str()); + } +} + +template +std::vector enumerate(Function&& function, const char* operation) +{ + for (;;) + { + std::uint32_t count = 0; + check(function(&count, nullptr), operation); + std::vector values(count); + const VkResult result = function(&count, values.data()); + if (result == VK_INCOMPLETE) + { + continue; + } + check(result, operation); + values.resize(count); + return values; + } +} + +template +bool hasName(const std::vector& properties, const char* expected) +{ + return std::any_of(properties.begin(), properties.end(), [expected](const Property& property) + { + if constexpr (std::is_same_v) + { + return std::strcmp(property.layerName, expected) == 0; + } + else + { + return std::strcmp(property.extensionName, expected) == 0; + } + }); +} + +struct Options +{ + std::filesystem::path mShaderDirectory; + std::filesystem::path mOutput; +}; + +bool parseOptions(int argc, char** argv, Options& options, std::string& error) +{ + bool have_shader_directory = false; + bool have_output = false; + for (int index = 1; index < argc; ++index) + { + const std::string argument = argv[index]; + if (argument != "--shader-dir" && argument != "--output") + { + error = "unknown argument: " + argument; + return false; + } + if (index + 1 >= argc) + { + error = argument + " requires a path"; + return false; + } + + const std::filesystem::path value = argv[++index]; + if (value.empty()) + { + error = argument + " requires a non-empty path"; + return false; + } + if (argument == "--shader-dir") + { + if (have_shader_directory) + { + error = "--shader-dir was specified more than once"; + return false; + } + options.mShaderDirectory = value; + have_shader_directory = true; + } + else + { + if (have_output) + { + error = "--output was specified more than once"; + return false; + } + options.mOutput = value; + have_output = true; + } + } + + if (!have_shader_directory || !have_output) + { + error = "both --shader-dir and --output are required"; + return false; + } + return true; +} + +struct ValidationState +{ + std::atomic mMessages{ 0 }; + std::mutex mMutex; + std::string mFirstMessage; +}; + +VKAPI_ATTR VkBool32 VKAPI_CALL validationCallback( + VkDebugUtilsMessageSeverityFlagBitsEXT, + VkDebugUtilsMessageTypeFlagsEXT, + const VkDebugUtilsMessengerCallbackDataEXT* callback_data, + void* user_data) +{ + auto& state = *static_cast(user_data); + ++state.mMessages; + std::lock_guard lock(state.mMutex); + if (state.mFirstMessage.empty() && callback_data && callback_data->pMessage) + { + state.mFirstMessage = callback_data->pMessage; + } + return VK_FALSE; +} + +std::uint32_t rotateRight(std::uint32_t value, std::uint32_t shift) +{ + return (value >> shift) | (value << (32U - shift)); +} + +ShaderIdentityToken sha256(const std::vector& input) +{ + constexpr std::array K{ + 0x428a2f98U, 0x71374491U, 0xb5c0fbcfU, 0xe9b5dba5U, 0x3956c25bU, 0x59f111f1U, 0x923f82a4U, 0xab1c5ed5U, + 0xd807aa98U, 0x12835b01U, 0x243185beU, 0x550c7dc3U, 0x72be5d74U, 0x80deb1feU, 0x9bdc06a7U, 0xc19bf174U, + 0xe49b69c1U, 0xefbe4786U, 0x0fc19dc6U, 0x240ca1ccU, 0x2de92c6fU, 0x4a7484aaU, 0x5cb0a9dcU, 0x76f988daU, + 0x983e5152U, 0xa831c66dU, 0xb00327c8U, 0xbf597fc7U, 0xc6e00bf3U, 0xd5a79147U, 0x06ca6351U, 0x14292967U, + 0x27b70a85U, 0x2e1b2138U, 0x4d2c6dfcU, 0x53380d13U, 0x650a7354U, 0x766a0abbU, 0x81c2c92eU, 0x92722c85U, + 0xa2bfe8a1U, 0xa81a664bU, 0xc24b8b70U, 0xc76c51a3U, 0xd192e819U, 0xd6990624U, 0xf40e3585U, 0x106aa070U, + 0x19a4c116U, 0x1e376c08U, 0x2748774cU, 0x34b0bcb5U, 0x391c0cb3U, 0x4ed8aa4aU, 0x5b9cca4fU, 0x682e6ff3U, + 0x748f82eeU, 0x78a5636fU, 0x84c87814U, 0x8cc70208U, 0x90befffaU, 0xa4506cebU, 0xbef9a3f7U, 0xc67178f2U + }; + std::array state{ 0x6a09e667U, 0xbb67ae85U, 0x3c6ef372U, 0xa54ff53aU, + 0x510e527fU, 0x9b05688cU, 0x1f83d9abU, 0x5be0cd19U }; + std::vector padded = input; + const std::uint64_t bit_size = static_cast(input.size()) * 8U; + padded.push_back(0x80U); + while (padded.size() % 64 != 56) + { + padded.push_back(0); + } + for (int shift = 56; shift >= 0; shift -= 8) + { + padded.push_back(static_cast(bit_size >> shift)); + } + + for (std::size_t block = 0; block < padded.size(); block += 64) + { + std::array words{}; + for (std::size_t word = 0; word < 16; ++word) + { + const std::size_t byte = block + word * 4; + words[word] = (static_cast(padded[byte]) << 24) | + (static_cast(padded[byte + 1]) << 16) | + (static_cast(padded[byte + 2]) << 8) | + static_cast(padded[byte + 3]); + } + for (std::size_t word = 16; word < words.size(); ++word) + { + const std::uint32_t s0 = rotateRight(words[word - 15], 7) ^ rotateRight(words[word - 15], 18) ^ + (words[word - 15] >> 3); + const std::uint32_t s1 = rotateRight(words[word - 2], 17) ^ rotateRight(words[word - 2], 19) ^ + (words[word - 2] >> 10); + words[word] = words[word - 16] + s0 + words[word - 7] + s1; + } + + std::uint32_t a = state[0]; + std::uint32_t b = state[1]; + std::uint32_t c = state[2]; + std::uint32_t d = state[3]; + std::uint32_t e = state[4]; + std::uint32_t f = state[5]; + std::uint32_t g = state[6]; + std::uint32_t h = state[7]; + for (std::size_t round = 0; round < words.size(); ++round) + { + const std::uint32_t sum1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25); + const std::uint32_t choose = (e & f) ^ (~e & g); + const std::uint32_t temporary1 = h + sum1 + choose + K[round] + words[round]; + const std::uint32_t sum0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22); + const std::uint32_t majority = (a & b) ^ (a & c) ^ (b & c); + const std::uint32_t temporary2 = sum0 + majority; + h = g; + g = f; + f = e; + e = d + temporary1; + d = c; + c = b; + b = a; + a = temporary1 + temporary2; + } + state[0] += a; + state[1] += b; + state[2] += c; + state[3] += d; + state[4] += e; + state[5] += f; + state[6] += g; + state[7] += h; + } + + ShaderIdentityToken result{}; + for (std::size_t word = 0; word < state.size(); ++word) + { + result[word * 4] = static_cast(state[word] >> 24); + result[word * 4 + 1] = static_cast(state[word] >> 16); + result[word * 4 + 2] = static_cast(state[word] >> 8); + result[word * 4 + 3] = static_cast(state[word]); + } + return result; +} + +struct Buffer +{ + VkBuffer mBuffer = VK_NULL_HANDLE; + VkDeviceMemory mMemory = VK_NULL_HANDLE; + VkDeviceSize mSize = 0; + VkDeviceSize mAllocationSize = 0; + VkMemoryPropertyFlags mMemoryProperties = 0; + void* mMapped = nullptr; +}; + +struct Image +{ + VkImage mImage = VK_NULL_HANDLE; + VkDeviceMemory mMemory = VK_NULL_HANDLE; + VkImageView mView = VK_NULL_HANDLE; + VkFormat mFormat = VK_FORMAT_UNDEFINED; + Extent2D mExtent; + std::uint32_t mMipLevels = 0; + VkImageUsageFlags mUsage = 0; + VkImageAspectFlags mAspect = 0; + VkImageLayout mLayout = VK_IMAGE_LAYOUT_UNDEFINED; +}; + +struct MaterialReadback +{ + std::array mGBuffer0{}; + std::array mGBuffer1{}; + std::array mGBuffer2{}; + std::array mDepth{}; +}; + +struct DepthGate +{ + std::uint32_t mPasses = 0; + std::uint32_t mFailures = 0; + std::uint32_t mMirrorClippedPasses = 0; + bool mValid = false; +}; + +enum class RegistryMutation +{ + None, + StaleVertex, + StaleIndex, + StaleDiffuse, + StaleNormal, + StaleSpecular, + StaleSampler, + StalePipeline, + StaleGBuffer0, + StaleGBuffer1, + StaleGBuffer2, + StaleDepth, + WrongProgram, + WrongVariant, + WrongVertexLayout, + WrongTranslatedIndices, + WrongTextureFormat, + WrongTextureExtent, + WrongTextureMips, + MissingUsageBit, + LiveLayoutDrift, + ViewRangeDrift, + WrongSampler, + WrongColorFormat, + WrongTargetExtent, + WrongDepthFormat, + WrongDescriptors, + WrongParameterRange, + WrongParameterOffset, + WrongAttachmentView, + AttachmentAliasing, + WrongIndexType, + WrongTopology, + WrongRenderPassMetadata, + WrongPipelineMetadata, + WrongShaderIdentity +}; + +enum class FrameMutation +{ + None, + WrongLayout, + WrongImageRange, + WrongIndexRange, + WrongParameterSize +}; + +struct RejectionSpec +{ + const char* mName; + RegistryMutation mRegistryMutation; + FrameMutation mFrameMutation; +}; + +constexpr std::array REJECTIONS{ + RejectionSpec{ "stale_vertex", RegistryMutation::StaleVertex, FrameMutation::None }, + RejectionSpec{ "stale_index", RegistryMutation::StaleIndex, FrameMutation::None }, + RejectionSpec{ "stale_diffuse", RegistryMutation::StaleDiffuse, FrameMutation::None }, + RejectionSpec{ "stale_normal", RegistryMutation::StaleNormal, FrameMutation::None }, + RejectionSpec{ "stale_specular", RegistryMutation::StaleSpecular, FrameMutation::None }, + RejectionSpec{ "stale_sampler", RegistryMutation::StaleSampler, FrameMutation::None }, + RejectionSpec{ "stale_pipeline", RegistryMutation::StalePipeline, FrameMutation::None }, + RejectionSpec{ "stale_gbuffer0", RegistryMutation::StaleGBuffer0, FrameMutation::None }, + RejectionSpec{ "stale_gbuffer1", RegistryMutation::StaleGBuffer1, FrameMutation::None }, + RejectionSpec{ "stale_gbuffer2", RegistryMutation::StaleGBuffer2, FrameMutation::None }, + RejectionSpec{ "stale_depth", RegistryMutation::StaleDepth, FrameMutation::None }, + RejectionSpec{ "wrong_program", RegistryMutation::WrongProgram, FrameMutation::None }, + RejectionSpec{ "wrong_variant", RegistryMutation::WrongVariant, FrameMutation::None }, + RejectionSpec{ "wrong_vertex_layout", RegistryMutation::WrongVertexLayout, FrameMutation::None }, + RejectionSpec{ "wrong_translated_indices", RegistryMutation::WrongTranslatedIndices, FrameMutation::None }, + RejectionSpec{ "wrong_texture_format", RegistryMutation::WrongTextureFormat, FrameMutation::None }, + RejectionSpec{ "wrong_texture_extent", RegistryMutation::WrongTextureExtent, FrameMutation::None }, + RejectionSpec{ "wrong_texture_mips", RegistryMutation::WrongTextureMips, FrameMutation::None }, + RejectionSpec{ "missing_usage_bit", RegistryMutation::MissingUsageBit, FrameMutation::None }, + RejectionSpec{ "live_layout_drift", RegistryMutation::LiveLayoutDrift, FrameMutation::None }, + RejectionSpec{ "view_range_drift", RegistryMutation::ViewRangeDrift, FrameMutation::None }, + RejectionSpec{ "wrong_sampler", RegistryMutation::WrongSampler, FrameMutation::None }, + RejectionSpec{ "wrong_color_format", RegistryMutation::WrongColorFormat, FrameMutation::None }, + RejectionSpec{ "wrong_target_extent", RegistryMutation::WrongTargetExtent, FrameMutation::None }, + RejectionSpec{ "wrong_depth_format", RegistryMutation::WrongDepthFormat, FrameMutation::None }, + RejectionSpec{ "wrong_descriptors", RegistryMutation::WrongDescriptors, FrameMutation::None }, + RejectionSpec{ "wrong_parameter_range", RegistryMutation::WrongParameterRange, FrameMutation::None }, + RejectionSpec{ "wrong_parameter_offset", RegistryMutation::WrongParameterOffset, FrameMutation::None }, + RejectionSpec{ "wrong_attachment_view", RegistryMutation::WrongAttachmentView, FrameMutation::None }, + RejectionSpec{ "attachment_aliasing", RegistryMutation::AttachmentAliasing, FrameMutation::None }, + RejectionSpec{ "wrong_index_type", RegistryMutation::WrongIndexType, FrameMutation::None }, + RejectionSpec{ "wrong_topology", RegistryMutation::WrongTopology, FrameMutation::None }, + RejectionSpec{ "wrong_render_pass_metadata", RegistryMutation::WrongRenderPassMetadata, FrameMutation::None }, + RejectionSpec{ "wrong_pipeline_metadata", RegistryMutation::WrongPipelineMetadata, FrameMutation::None }, + RejectionSpec{ "wrong_shader_identity", RegistryMutation::WrongShaderIdentity, FrameMutation::None }, + RejectionSpec{ "frame_wrong_layout", RegistryMutation::None, FrameMutation::WrongLayout }, + RejectionSpec{ "frame_wrong_image_range", RegistryMutation::None, FrameMutation::WrongImageRange }, + RejectionSpec{ "frame_wrong_index_range", RegistryMutation::None, FrameMutation::WrongIndexRange }, + RejectionSpec{ "frame_wrong_parameter_size", RegistryMutation::None, FrameMutation::WrongParameterSize } +}; + +template +Handle stale(Handle handle) +{ + ++handle.mGeneration; + return handle; +} + +class VulkanMaterialRun +{ +public: + explicit VulkanMaterialRun(std::filesystem::path shader_directory) + : mShaderDirectory(std::move(shader_directory)) + { + } + + ~VulkanMaterialRun() + { + shutdown(); + } + + MaterialArtifact run(); + void shutdown() noexcept; + + std::uint32_t validationMessageCount() const noexcept { return mValidation.mMessages.load(); } + std::string firstValidationMessage() + { + std::lock_guard lock(mValidation.mMutex); + return mValidation.mFirstMessage; + } + std::uint64_t executorRecordingCount() const noexcept { return mExecutorRecordings; } + std::uint64_t executorSubmissionCount() const noexcept { return mExecutorSubmissions; } + std::size_t rejectionCount() const noexcept { return mRejectionCount; } + bool usedPortabilityEnumeration() const noexcept { return mPortabilityEnumeration; } + bool usedPortabilitySubset() const noexcept { return mPortabilitySubset; } + std::uint32_t vendorId() const noexcept { return mDeviceProperties.vendorID; } + std::uint32_t deviceId() const noexcept { return mDeviceProperties.deviceID; } + std::uint32_t apiVersion() const noexcept { return mDeviceProperties.apiVersion; } + std::uint32_t driverVersion() const noexcept { return mDeviceProperties.driverVersion; } + const DepthGate& depthGate() const noexcept { return mDepthGate; } + +private: + void loadShaderFiles(); + void createInstance(); + bool hasRequiredFormats(VkPhysicalDevice physical_device) const; + bool hasHostCoherentMemory(VkPhysicalDevice physical_device) const; + std::optional graphicsQueueFamily(VkPhysicalDevice physical_device) const; + std::vector deviceExtensions(VkPhysicalDevice physical_device) const; + void selectPhysicalDevice(); + void createDevice(); + std::uint32_t memoryType(std::uint32_t type_bits, VkMemoryPropertyFlags required, + VkMemoryPropertyFlags preferred, VkMemoryPropertyFlags& selected_properties) const; + Buffer createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, bool persistent_map = false); + Image createImage(VkFormat format, Extent2D extent, std::uint32_t mip_levels, + VkImageUsageFlags usage, VkImageAspectFlags aspect); + void destroyBuffer(Buffer& buffer) noexcept; + void destroyImage(Image& image) noexcept; + void writeBuffer(const Buffer& buffer, const void* source, std::size_t size, VkDeviceSize offset = 0); + std::vector readBuffer(const Buffer& buffer, std::size_t size) const; + void submitImmediate(const std::function& commands); + void createCommandResources(); + void createFixtureBuffers(const MaterialFixture& fixture); + void createTextures(const MaterialFixture& fixture); + void uploadTexture(Image& image, const std::array& bytes); + void createSampler(); + void createDescriptorResources(); + void createTargets(); + void createPipeline(); + VkShaderModule createShaderModule(const std::vector& words); + LLRenderVulkanMaterial::ExecutionContext executionContext(); + LLRenderVulkanMaterial::Registry makeRegistry(const MaterialCase& diagnostic_case, RegistryMutation mutation); + void mutateFrame(FrameSnapshot& frame, FrameMutation mutation) const; + void seedTargets(const MaterialFixture& fixture); + void seedImage(Image& image, const std::vector& bytes, VkImageLayout final_layout, + VkAccessFlags final_access, VkPipelineStageFlags final_stage); + std::vector readImage(Image& image); + std::array, 4> targetSnapshot(); + void runRejections(const MaterialCase& diagnostic_case, const MaterialFixture& fixture); + MaterialReadback readTargets(); + MaterialArtifact artifactFrom(const MaterialReadback& readback) const; + DepthGate verifyDepthGate(const MaterialFixture& fixture, const MaterialReadback& readback) const; + bool nontrivialOutput(const MaterialFixture& fixture, const MaterialReadback& readback, DepthGate& gate) const; + + std::filesystem::path mShaderDirectory; + std::vector mVertexSpirv; + std::vector mFragmentSpirv; + ShaderIdentityToken mVertexIdentity{}; + ShaderIdentityToken mFragmentIdentity{}; + + std::optional mGlobalDispatch; + VkInstance mInstance = VK_NULL_HANDLE; + VkDebugUtilsMessengerEXT mDebugMessenger = VK_NULL_HANDLE; + VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE; + VkPhysicalDeviceProperties mDeviceProperties{}; + std::uint32_t mQueueFamily = 0; + VkDevice mDevice = VK_NULL_HANDLE; + VkQueue mQueue = VK_NULL_HANDLE; + VkCommandPool mCommandPool = VK_NULL_HANDLE; + VkCommandBuffer mCommandBuffer = VK_NULL_HANDLE; + + Buffer mTransferBuffer; + Buffer mVertexBuffer; + Buffer mIndexBuffer; + Buffer mParameterBuffer; + std::array mTextures; + VkSampler mSampler = VK_NULL_HANDLE; + std::array mDescriptorSetLayouts{}; + VkPipelineLayout mPipelineLayout = VK_NULL_HANDLE; + VkDescriptorPool mDescriptorPool = VK_NULL_HANDLE; + std::array mDescriptorSets{}; + std::array mColors; + Image mDepth; + VkRenderPass mRenderPass = VK_NULL_HANDLE; + VkFramebuffer mFramebuffer = VK_NULL_HANDLE; + VkPipeline mPipeline = VK_NULL_HANDLE; + + ValidationState mValidation; + std::uint64_t mExecutorRecordings = 0; + std::uint64_t mExecutorSubmissions = 0; + std::size_t mRejectionCount = 0; + bool mPortabilityEnumeration = false; + bool mPortabilitySubset = false; + DepthGate mDepthGate; +}; + +void VulkanMaterialRun::loadShaderFiles() +{ + std::error_code error; + if (!std::filesystem::is_directory(mShaderDirectory, error) || error) + { + throw Failure("shader directory is not readable: " + mShaderDirectory.string()); + } + + auto read = [&error](const std::filesystem::path& path, + std::vector& words, + ShaderIdentityToken& identity) + { + error.clear(); + const std::uintmax_t size = std::filesystem::file_size(path, error); + if (error || size < sizeof(std::uint32_t) || size % sizeof(std::uint32_t) != 0 || size > 16U * 1024U * 1024U) + { + throw Failure("SPIR-V file is missing or has an invalid size: " + path.string()); + } + std::ifstream input(path, std::ios::binary | std::ios::in); + std::vector bytes(static_cast(size)); + if (!input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) + { + throw Failure("cannot read complete SPIR-V file: " + path.string()); + } + words.resize(bytes.size() / sizeof(std::uint32_t)); + std::memcpy(words.data(), bytes.data(), bytes.size()); + if (words.front() != 0x07230203U) + { + throw Failure("SPIR-V file has the wrong magic word: " + path.string()); + } + identity = sha256(bytes); + }; + + read(mShaderDirectory / "material.vert.spv", mVertexSpirv, mVertexIdentity); + read(mShaderDirectory / "material.frag.spv", mFragmentSpirv, mFragmentIdentity); +} + +void VulkanMaterialRun::createInstance() +{ + LLRenderVulkan::VulkanGlobalDispatchResolutionResult dispatch_result = + LLRenderVulkan::resolveVulkanGlobalDispatchGeneration(vkGetInstanceProcAddr); + if (const auto* error = std::get_if(&dispatch_result)) + { + if (error->mCode == LLRenderVulkan::VulkanGlobalDispatchResolutionCode::VersionQueryFailure) + { + check(error->mResult, "vkEnumerateInstanceVersion"); + } + if (error->mCode == LLRenderVulkan::VulkanGlobalDispatchResolutionCode::UnsupportedApiVariant) + { + throw CapabilityFailure("the Vulkan loader reported an unsupported API variant"); + } + if (error->mCode == LLRenderVulkan::VulkanGlobalDispatchResolutionCode::InsufficientApiVersion) + { + throw CapabilityFailure("the Vulkan 1.1 loader required by the shader target is unavailable"); + } + throw CapabilityFailure("the Vulkan global command set required by the shader target is unavailable"); + } + mGlobalDispatch.emplace(std::get(std::move(dispatch_result))); + const LLRenderVulkan::VulkanGlobalDispatchGeneration& global_dispatch = *mGlobalDispatch; + + const auto layers = enumerate( + [&global_dispatch](std::uint32_t* count, VkLayerProperties* values) + { + return global_dispatch.enumerateInstanceLayerProperties()(count, values); + }, + "vkEnumerateInstanceLayerProperties"); + if (!hasName(layers, "VK_LAYER_KHRONOS_validation")) + { + throw CapabilityFailure("VK_LAYER_KHRONOS_validation is required but unavailable"); + } + + const auto extensions = enumerate( + [&global_dispatch](std::uint32_t* count, VkExtensionProperties* values) + { + return global_dispatch.enumerateInstanceExtensionProperties()(nullptr, count, values); + }, + "vkEnumerateInstanceExtensionProperties"); + if (!hasName(extensions, VK_EXT_DEBUG_UTILS_EXTENSION_NAME)) + { + throw CapabilityFailure("VK_EXT_debug_utils is required but unavailable"); + } + + std::vector enabled_extensions{ VK_EXT_DEBUG_UTILS_EXTENSION_NAME }; + VkInstanceCreateFlags flags = 0; + if (hasName(extensions, PORTABILITY_ENUMERATION_EXTENSION)) + { + enabled_extensions.push_back(PORTABILITY_ENUMERATION_EXTENSION); + flags |= VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR; + mPortabilityEnumeration = true; + } + + VkDebugUtilsMessengerCreateInfoEXT debug_info{}; + debug_info.sType = VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT; + debug_info.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT; + debug_info.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT; + debug_info.pfnUserCallback = validationCallback; + debug_info.pUserData = &mValidation; + + VkApplicationInfo application{}; + application.sType = VK_STRUCTURE_TYPE_APPLICATION_INFO; + application.pApplicationName = "llvulkanmaterial"; + application.applicationVersion = 1; + application.pEngineName = "Second Life material diagnostic"; + application.engineVersion = 1; + application.apiVersion = LLRenderVulkan::RENDERER_VULKAN_API_VERSION; + + const char* validation_layer = "VK_LAYER_KHRONOS_validation"; + VkInstanceCreateInfo create_info{}; + create_info.sType = VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO; + create_info.pNext = &debug_info; + create_info.flags = flags; + create_info.pApplicationInfo = &application; + create_info.enabledLayerCount = 1; + create_info.ppEnabledLayerNames = &validation_layer; + create_info.enabledExtensionCount = static_cast(enabled_extensions.size()); + create_info.ppEnabledExtensionNames = enabled_extensions.data(); + VkInstance instance = VK_NULL_HANDLE; + const VkResult instance_result = global_dispatch.createInstance()(&create_info, nullptr, &instance); + check(instance_result, "vkCreateInstance"); + if (instance == VK_NULL_HANDLE) + { + throw Failure("vkCreateInstance returned success with a null instance"); + } + mInstance = instance; + + const auto create_debug = reinterpret_cast( + global_dispatch.getInstanceProcAddr()(mInstance, "vkCreateDebugUtilsMessengerEXT")); + if (!create_debug) + { + throw Failure("vkCreateDebugUtilsMessengerEXT is unavailable after enabling VK_EXT_debug_utils"); + } + VkDebugUtilsMessengerEXT debug_messenger = VK_NULL_HANDLE; + const VkResult debug_result = create_debug(mInstance, &debug_info, nullptr, &debug_messenger); + check(debug_result, "vkCreateDebugUtilsMessengerEXT"); + if (debug_messenger == VK_NULL_HANDLE) + { + throw Failure("vkCreateDebugUtilsMessengerEXT returned success with a null messenger"); + } + mDebugMessenger = debug_messenger; +} + +bool VulkanMaterialRun::hasRequiredFormats(VkPhysicalDevice physical_device) const +{ + struct FormatRequirement + { + VkFormat mFormat; + VkFormatFeatureFlags mFeatures; + bool mBufferFeatures; + }; + constexpr std::array requirements{ + FormatRequirement{ VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT | + VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | VK_FORMAT_FEATURE_TRANSFER_SRC_BIT | + VK_FORMAT_FEATURE_TRANSFER_DST_BIT, + false }, + FormatRequirement{ VK_FORMAT_R16G16B16A16_UNORM, + VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | + VK_FORMAT_FEATURE_TRANSFER_SRC_BIT | + VK_FORMAT_FEATURE_TRANSFER_DST_BIT, + false }, + FormatRequirement{ VK_FORMAT_D32_SFLOAT, + VK_FORMAT_FEATURE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_FORMAT_FEATURE_TRANSFER_SRC_BIT | + VK_FORMAT_FEATURE_TRANSFER_DST_BIT, + false }, + FormatRequirement{ VK_FORMAT_R32G32B32_SFLOAT, VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT, true }, + FormatRequirement{ VK_FORMAT_R32G32_SFLOAT, VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT, true }, + FormatRequirement{ VK_FORMAT_R8G8B8A8_UNORM, VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT, true }, + FormatRequirement{ VK_FORMAT_R32G32B32A32_SFLOAT, VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT, true } + }; + for (const FormatRequirement& requirement : requirements) + { + VkFormatProperties properties{}; + vkGetPhysicalDeviceFormatProperties(physical_device, requirement.mFormat, &properties); + const VkFormatFeatureFlags available = requirement.mBufferFeatures + ? properties.bufferFeatures + : properties.optimalTilingFeatures; + if ((available & requirement.mFeatures) != requirement.mFeatures) + { + return false; + } + } + + struct ImageRequirement + { + VkFormat mFormat; + VkImageUsageFlags mUsage; + Extent2D mExtent; + std::uint32_t mMipLevels; + }; + constexpr std::array image_requirements{ + ImageRequirement{ VK_FORMAT_R8G8B8A8_UNORM, + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, + TEXTURE_EXTENT, + LLRenderContract::MATERIAL_TEXTURE_MIP_LEVELS }, + ImageRequirement{ VK_FORMAT_R8G8B8A8_UNORM, + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + FRAME_EXTENT, + 1 }, + ImageRequirement{ VK_FORMAT_R16G16B16A16_UNORM, + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT, + FRAME_EXTENT, + 1 }, + ImageRequirement{ VK_FORMAT_D32_SFLOAT, + VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT | VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT, + FRAME_EXTENT, + 1 } + }; + for (const ImageRequirement& requirement : image_requirements) + { + VkImageFormatProperties properties{}; + if (vkGetPhysicalDeviceImageFormatProperties(physical_device, requirement.mFormat, VK_IMAGE_TYPE_2D, + VK_IMAGE_TILING_OPTIMAL, requirement.mUsage, 0, &properties) != VK_SUCCESS || + properties.maxExtent.width < requirement.mExtent.mWidth || + properties.maxExtent.height < requirement.mExtent.mHeight || + properties.maxMipLevels < requirement.mMipLevels || properties.maxArrayLayers < 1 || + (properties.sampleCounts & VK_SAMPLE_COUNT_1_BIT) == 0) + { + return false; + } + } + return true; +} + +bool VulkanMaterialRun::hasHostCoherentMemory(VkPhysicalDevice physical_device) const +{ + VkPhysicalDeviceMemoryProperties properties{}; + vkGetPhysicalDeviceMemoryProperties(physical_device, &properties); + for (std::uint32_t index = 0; index < properties.memoryTypeCount; ++index) + { + constexpr VkMemoryPropertyFlags REQUIRED = + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + if ((properties.memoryTypes[index].propertyFlags & REQUIRED) == REQUIRED) + { + return true; + } + } + return false; +} + +std::optional VulkanMaterialRun::graphicsQueueFamily(VkPhysicalDevice physical_device) const +{ + std::uint32_t count = 0; + vkGetPhysicalDeviceQueueFamilyProperties(physical_device, &count, nullptr); + std::vector properties(count); + vkGetPhysicalDeviceQueueFamilyProperties(physical_device, &count, properties.data()); + for (std::uint32_t index = 0; index < count; ++index) + { + if (properties[index].queueCount != 0 && (properties[index].queueFlags & VK_QUEUE_GRAPHICS_BIT) != 0) + { + return index; + } + } + return std::nullopt; +} + +std::vector VulkanMaterialRun::deviceExtensions(VkPhysicalDevice physical_device) const +{ + return enumerate( + [physical_device](std::uint32_t* count, VkExtensionProperties* values) + { + return vkEnumerateDeviceExtensionProperties(physical_device, nullptr, count, values); + }, + "vkEnumerateDeviceExtensionProperties"); +} + +void VulkanMaterialRun::selectPhysicalDevice() +{ + const auto devices = enumerate( + [this](std::uint32_t* count, VkPhysicalDevice* values) + { + return vkEnumeratePhysicalDevices(mInstance, count, values); + }, + "vkEnumeratePhysicalDevices"); + for (VkPhysicalDevice device : devices) + { + VkPhysicalDeviceProperties properties{}; + VkPhysicalDeviceFeatures features{}; + vkGetPhysicalDeviceProperties(device, &properties); + vkGetPhysicalDeviceFeatures(device, &features); + const auto queue_family = graphicsQueueFamily(device); + const auto& limits = properties.limits; + if (properties.apiVersion < VK_API_VERSION_1_1 || features.samplerAnisotropy != VK_TRUE || + limits.maxSamplerAnisotropy < 8.f || limits.maxUniformBufferRange < sizeof(MaterialParameters) || + limits.maxVertexInputBindings < VERTEX_STRIDES.size() || + limits.maxVertexInputAttributes < VERTEX_FORMATS.size() || limits.maxColorAttachments < 3 || + limits.maxBoundDescriptorSets < 2 || limits.maxPerStageDescriptorSamplers < 3 || + limits.maxDescriptorSetSamplers < 3 || limits.maxPerStageDescriptorSampledImages < 3 || + limits.maxDescriptorSetSampledImages < 3 || limits.maxPerStageResources < 4 || + limits.maxPerStageDescriptorUniformBuffers < 1 || + limits.maxDescriptorSetUniformBuffers < 1 || limits.maxFramebufferWidth < FRAME_EXTENT.mWidth || + limits.maxFramebufferHeight < FRAME_EXTENT.mHeight || !queue_family || !hasRequiredFormats(device) || + !hasHostCoherentMemory(device)) + { + continue; + } + mPhysicalDevice = device; + mDeviceProperties = properties; + mQueueFamily = *queue_family; + const auto extensions = deviceExtensions(device); + mPortabilitySubset = hasName(extensions, PORTABILITY_SUBSET_EXTENSION); + return; + } + throw CapabilityFailure("no Vulkan 1.1 graphics device supports the exact material formats, limits, and anisotropy"); +} + +void VulkanMaterialRun::createDevice() +{ + const float priority = 1.f; + VkDeviceQueueCreateInfo queue_info{}; + queue_info.sType = VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO; + queue_info.queueFamilyIndex = mQueueFamily; + queue_info.queueCount = 1; + queue_info.pQueuePriorities = &priority; + + std::vector extensions; + if (mPortabilitySubset) + { + extensions.push_back(PORTABILITY_SUBSET_EXTENSION); + } + + VkPhysicalDeviceFeatures features{}; + features.samplerAnisotropy = VK_TRUE; + VkDeviceCreateInfo create_info{}; + create_info.sType = VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO; + create_info.queueCreateInfoCount = 1; + create_info.pQueueCreateInfos = &queue_info; + create_info.enabledExtensionCount = static_cast(extensions.size()); + create_info.ppEnabledExtensionNames = extensions.data(); + create_info.pEnabledFeatures = &features; + check(vkCreateDevice(mPhysicalDevice, &create_info, nullptr, &mDevice), "vkCreateDevice"); + vkGetDeviceQueue(mDevice, mQueueFamily, 0, &mQueue); + if (mQueue == VK_NULL_HANDLE) + { + throw Failure("vkGetDeviceQueue returned a null graphics queue"); + } +} + +std::uint32_t VulkanMaterialRun::memoryType(std::uint32_t type_bits, VkMemoryPropertyFlags required, + VkMemoryPropertyFlags preferred, + VkMemoryPropertyFlags& selected_properties) const +{ + VkPhysicalDeviceMemoryProperties properties{}; + vkGetPhysicalDeviceMemoryProperties(mPhysicalDevice, &properties); + for (int pass = 0; pass < 2; ++pass) + { + for (std::uint32_t index = 0; index < properties.memoryTypeCount; ++index) + { + const VkMemoryPropertyFlags flags = properties.memoryTypes[index].propertyFlags; + const bool preferred_match = (flags & preferred) == preferred; + if ((type_bits & (1U << index)) != 0 && (flags & required) == required && + (pass != 0 || preferred_match)) + { + selected_properties = flags; + return index; + } + } + } + throw Failure("no Vulkan memory type satisfies the required properties"); +} + +Buffer VulkanMaterialRun::createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, bool persistent_map) +{ + Buffer result; + result.mSize = size; + VkBufferCreateInfo create_info{}; + create_info.sType = VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO; + create_info.size = size; + create_info.usage = usage; + create_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + check(vkCreateBuffer(mDevice, &create_info, nullptr, &result.mBuffer), "vkCreateBuffer"); + try + { + VkMemoryRequirements requirements{}; + vkGetBufferMemoryRequirements(mDevice, result.mBuffer, &requirements); + const VkMemoryPropertyFlags required = + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT; + VkMemoryPropertyFlags selected = 0; + const std::uint32_t memory_type = memoryType(requirements.memoryTypeBits, required, required, selected); + result.mAllocationSize = requirements.size; + result.mMemoryProperties = selected; + VkMemoryAllocateInfo allocation{}; + allocation.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = memory_type; + check(vkAllocateMemory(mDevice, &allocation, nullptr, &result.mMemory), "vkAllocateMemory(buffer)"); + check(vkBindBufferMemory(mDevice, result.mBuffer, result.mMemory, 0), "vkBindBufferMemory"); + if (persistent_map) + { + check(vkMapMemory(mDevice, result.mMemory, 0, VK_WHOLE_SIZE, 0, &result.mMapped), "vkMapMemory(parameters)"); + } + return result; + } + catch (...) + { + if (result.mMapped) + { + vkUnmapMemory(mDevice, result.mMemory); + } + if (result.mBuffer != VK_NULL_HANDLE) + { + vkDestroyBuffer(mDevice, result.mBuffer, nullptr); + } + if (result.mMemory != VK_NULL_HANDLE) + { + vkFreeMemory(mDevice, result.mMemory, nullptr); + } + throw; + } +} + +Image VulkanMaterialRun::createImage(VkFormat format, Extent2D extent, std::uint32_t mip_levels, + VkImageUsageFlags usage, VkImageAspectFlags aspect) +{ + Image result; + result.mFormat = format; + result.mExtent = extent; + result.mMipLevels = mip_levels; + result.mUsage = usage; + result.mAspect = aspect; + + VkImageCreateInfo create_info{}; + create_info.sType = VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO; + create_info.imageType = VK_IMAGE_TYPE_2D; + create_info.format = format; + create_info.extent = { extent.mWidth, extent.mHeight, 1 }; + create_info.mipLevels = mip_levels; + create_info.arrayLayers = 1; + create_info.samples = VK_SAMPLE_COUNT_1_BIT; + create_info.tiling = VK_IMAGE_TILING_OPTIMAL; + create_info.usage = usage; + create_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + create_info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + check(vkCreateImage(mDevice, &create_info, nullptr, &result.mImage), "vkCreateImage"); + try + { + VkMemoryRequirements requirements{}; + vkGetImageMemoryRequirements(mDevice, result.mImage, &requirements); + VkMemoryPropertyFlags selected = 0; + const std::uint32_t memory_type = + memoryType(requirements.memoryTypeBits, 0, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, selected); + VkMemoryAllocateInfo allocation{}; + allocation.sType = VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO; + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = memory_type; + check(vkAllocateMemory(mDevice, &allocation, nullptr, &result.mMemory), "vkAllocateMemory(image)"); + check(vkBindImageMemory(mDevice, result.mImage, result.mMemory, 0), "vkBindImageMemory"); + + VkImageViewCreateInfo view_info{}; + view_info.sType = VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO; + view_info.image = result.mImage; + view_info.viewType = VK_IMAGE_VIEW_TYPE_2D; + view_info.format = format; + view_info.subresourceRange.aspectMask = aspect; + view_info.subresourceRange.baseMipLevel = 0; + view_info.subresourceRange.levelCount = mip_levels; + view_info.subresourceRange.baseArrayLayer = 0; + view_info.subresourceRange.layerCount = 1; + check(vkCreateImageView(mDevice, &view_info, nullptr, &result.mView), "vkCreateImageView"); + return result; + } + catch (...) + { + if (result.mView != VK_NULL_HANDLE) + { + vkDestroyImageView(mDevice, result.mView, nullptr); + } + if (result.mImage != VK_NULL_HANDLE) + { + vkDestroyImage(mDevice, result.mImage, nullptr); + } + if (result.mMemory != VK_NULL_HANDLE) + { + vkFreeMemory(mDevice, result.mMemory, nullptr); + } + throw; + } +} + +void VulkanMaterialRun::destroyBuffer(Buffer& buffer) noexcept +{ + if (buffer.mMapped && buffer.mMemory != VK_NULL_HANDLE) + { + vkUnmapMemory(mDevice, buffer.mMemory); + } + if (buffer.mBuffer != VK_NULL_HANDLE) + { + vkDestroyBuffer(mDevice, buffer.mBuffer, nullptr); + } + if (buffer.mMemory != VK_NULL_HANDLE) + { + vkFreeMemory(mDevice, buffer.mMemory, nullptr); + } + buffer = {}; +} + +void VulkanMaterialRun::destroyImage(Image& image) noexcept +{ + if (image.mView != VK_NULL_HANDLE) + { + vkDestroyImageView(mDevice, image.mView, nullptr); + } + if (image.mImage != VK_NULL_HANDLE) + { + vkDestroyImage(mDevice, image.mImage, nullptr); + } + if (image.mMemory != VK_NULL_HANDLE) + { + vkFreeMemory(mDevice, image.mMemory, nullptr); + } + image = {}; +} + +void VulkanMaterialRun::writeBuffer(const Buffer& buffer, const void* source, std::size_t size, VkDeviceSize offset) +{ + if (!source || offset > buffer.mSize || size > buffer.mSize - offset) + { + throw Failure("host write exceeds a Vulkan buffer"); + } + if ((buffer.mMemoryProperties & (VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) != + (VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT | VK_MEMORY_PROPERTY_HOST_COHERENT_BIT)) + { + throw Failure("diagnostic buffer is not host-visible and coherent"); + } + + void* mapped = buffer.mMapped; + bool temporary_mapping = false; + if (!mapped) + { + check(vkMapMemory(mDevice, buffer.mMemory, 0, VK_WHOLE_SIZE, 0, &mapped), "vkMapMemory(write)"); + temporary_mapping = true; + } + std::memcpy(static_cast(mapped) + offset, source, size); + if (temporary_mapping) + { + vkUnmapMemory(mDevice, buffer.mMemory); + } +} + +std::vector VulkanMaterialRun::readBuffer(const Buffer& buffer, std::size_t size) const +{ + if (size > buffer.mSize || !buffer.mMapped) + { + throw Failure("host read exceeds or cannot access a persistently mapped Vulkan buffer"); + } + std::vector bytes(size); + std::memcpy(bytes.data(), buffer.mMapped, size); + return bytes; +} + +void VulkanMaterialRun::submitImmediate(const std::function& commands) +{ + check(vkResetCommandBuffer(mCommandBuffer, 0), "vkResetCommandBuffer(immediate)"); + VkCommandBufferBeginInfo begin{}; + begin.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + check(vkBeginCommandBuffer(mCommandBuffer, &begin), "vkBeginCommandBuffer(immediate)"); + commands(mCommandBuffer); + check(vkEndCommandBuffer(mCommandBuffer), "vkEndCommandBuffer(immediate)"); + VkSubmitInfo submit{}; + submit.sType = VK_STRUCTURE_TYPE_SUBMIT_INFO; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &mCommandBuffer; + check(vkQueueSubmit(mQueue, 1, &submit, VK_NULL_HANDLE), "vkQueueSubmit(immediate)"); + check(vkQueueWaitIdle(mQueue), "vkQueueWaitIdle(immediate)"); +} + +void VulkanMaterialRun::createCommandResources() +{ + VkCommandPoolCreateInfo pool_info{}; + pool_info.sType = VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO; + pool_info.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pool_info.queueFamilyIndex = mQueueFamily; + check(vkCreateCommandPool(mDevice, &pool_info, nullptr, &mCommandPool), "vkCreateCommandPool"); + + VkCommandBufferAllocateInfo allocation{}; + allocation.sType = VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO; + allocation.commandPool = mCommandPool; + allocation.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocation.commandBufferCount = 1; + check(vkAllocateCommandBuffers(mDevice, &allocation, &mCommandBuffer), "vkAllocateCommandBuffers"); + + mTransferBuffer = createBuffer(TRANSFER_BYTES, + VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + true); +} + +void VulkanMaterialRun::createFixtureBuffers(const MaterialFixture& fixture) +{ + if (fixture.mExtent.mWidth != FRAME_EXTENT.mWidth || fixture.mExtent.mHeight != FRAME_EXTENT.mHeight || + fixture.mRowOrigin != LLRenderContract::RowOrigin::BottomLeft || + fixture.mIndices != LLRenderContract::MATERIAL_INDICES || + LLRenderContract::materialFixtureFingerprint() != 0x4e52ab4e75b6748bULL) + { + throw Failure("material fixture identity or canonical indices changed"); + } + + mVertexBuffer = createBuffer(LLRenderContract::MATERIAL_VERTEX_BUFFER_SIZE, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + writeBuffer(mVertexBuffer, fixture.mVertexBytes.data(), fixture.mVertexBytes.size()); + + mIndexBuffer = createBuffer(LLRenderContract::MATERIAL_INDEX_BUFFER_SIZE, + VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + writeBuffer(mIndexBuffer, LLRenderVulkanMaterial::MATERIAL_VULKAN_INDICES.data(), + sizeof(LLRenderVulkanMaterial::MATERIAL_VULKAN_INDICES)); + + mParameterBuffer = createBuffer(sizeof(MaterialParameters), VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT, true); + std::array initial_parameters{}; + initial_parameters.fill(0xa5U); + writeBuffer(mParameterBuffer, initial_parameters.data(), initial_parameters.size()); +} + +void VulkanMaterialRun::uploadTexture( + Image& image, + const std::array& bytes) +{ + static_assert(TEXTURE_BYTES == LLRenderContract::MATERIAL_TEXTURE_COMPONENT_COUNT); + writeBuffer(mTransferBuffer, bytes.data(), bytes.size()); + submitImmediate([this, &image](VkCommandBuffer command_buffer) + { + VkBufferMemoryBarrier host_to_transfer{}; + host_to_transfer.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; + host_to_transfer.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT; + host_to_transfer.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + host_to_transfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host_to_transfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host_to_transfer.buffer = mTransferBuffer.mBuffer; + host_to_transfer.offset = 0; + host_to_transfer.size = TEXTURE_BYTES; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_HOST_BIT, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 0, nullptr, 1, &host_to_transfer, 0, nullptr); + + VkImageMemoryBarrier to_transfer{}; + to_transfer.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + to_transfer.srcAccessMask = 0; + to_transfer.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + to_transfer.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + to_transfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + to_transfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_transfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_transfer.image = image.mImage; + to_transfer.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + to_transfer.subresourceRange.baseMipLevel = 0; + to_transfer.subresourceRange.levelCount = image.mMipLevels; + to_transfer.subresourceRange.baseArrayLayer = 0; + to_transfer.subresourceRange.layerCount = 1; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, 0, nullptr, 1, &to_transfer); + + std::array copies{}; + for (std::uint32_t mip = 0; mip < copies.size(); ++mip) + { + copies[mip].bufferOffset = LLRenderContract::MATERIAL_TEXTURE_MIP_BYTE_OFFSETS[mip]; + copies[mip].imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + copies[mip].imageSubresource.mipLevel = mip; + copies[mip].imageSubresource.baseArrayLayer = 0; + copies[mip].imageSubresource.layerCount = 1; + copies[mip].imageExtent = { LLRenderContract::MATERIAL_TEXTURE_WIDTH >> mip, + LLRenderContract::MATERIAL_TEXTURE_HEIGHT >> mip, + 1 }; + } + vkCmdCopyBufferToImage(command_buffer, mTransferBuffer.mBuffer, image.mImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, + static_cast(copies.size()), copies.data()); + + VkImageMemoryBarrier to_shader = to_transfer; + to_shader.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + to_shader.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + to_shader.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + to_shader.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, + 0, nullptr, 0, nullptr, 1, &to_shader); + }); + image.mLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; +} + +void VulkanMaterialRun::createTextures(const MaterialFixture& fixture) +{ + constexpr VkImageUsageFlags USAGE = VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; + for (std::size_t index = 0; index < mTextures.size(); ++index) + { + mTextures[index] = createImage(VK_FORMAT_R8G8B8A8_UNORM, TEXTURE_EXTENT, + LLRenderContract::MATERIAL_TEXTURE_MIP_LEVELS, + USAGE, VK_IMAGE_ASPECT_COLOR_BIT); + uploadTexture(mTextures[index], fixture.mTextureRGBA8[index]); + } +} + +void VulkanMaterialRun::createSampler() +{ + VkSamplerCreateInfo create_info{}; + create_info.sType = VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO; + create_info.magFilter = VK_FILTER_LINEAR; + create_info.minFilter = VK_FILTER_LINEAR; + create_info.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + create_info.addressModeU = VK_SAMPLER_ADDRESS_MODE_REPEAT; + create_info.addressModeV = VK_SAMPLER_ADDRESS_MODE_REPEAT; + create_info.addressModeW = VK_SAMPLER_ADDRESS_MODE_REPEAT; + create_info.mipLodBias = 0.f; + create_info.anisotropyEnable = VK_TRUE; + create_info.maxAnisotropy = 8.f; + create_info.compareEnable = VK_FALSE; + create_info.compareOp = VK_COMPARE_OP_ALWAYS; + create_info.minLod = 0.f; + create_info.maxLod = 2.f; + create_info.borderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK; + create_info.unnormalizedCoordinates = VK_FALSE; + check(vkCreateSampler(mDevice, &create_info, nullptr, &mSampler), "vkCreateSampler(material)"); +} + +void VulkanMaterialRun::createDescriptorResources() +{ + VkDescriptorSetLayoutBinding uniform_binding{}; + uniform_binding.binding = 0; + uniform_binding.descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + uniform_binding.descriptorCount = 1; + uniform_binding.stageFlags = VK_SHADER_STAGE_VERTEX_BIT | VK_SHADER_STAGE_FRAGMENT_BIT; + + VkDescriptorSetLayoutCreateInfo uniform_layout{}; + uniform_layout.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + uniform_layout.bindingCount = 1; + uniform_layout.pBindings = &uniform_binding; + check(vkCreateDescriptorSetLayout(mDevice, &uniform_layout, nullptr, &mDescriptorSetLayouts[0]), + "vkCreateDescriptorSetLayout(parameters)"); + + std::array sampled_bindings{}; + for (std::uint32_t binding = 0; binding < sampled_bindings.size(); ++binding) + { + sampled_bindings[binding].binding = binding; + sampled_bindings[binding].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + sampled_bindings[binding].descriptorCount = 1; + sampled_bindings[binding].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + } + VkDescriptorSetLayoutCreateInfo sampled_layout{}; + sampled_layout.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO; + sampled_layout.bindingCount = static_cast(sampled_bindings.size()); + sampled_layout.pBindings = sampled_bindings.data(); + check(vkCreateDescriptorSetLayout(mDevice, &sampled_layout, nullptr, &mDescriptorSetLayouts[1]), + "vkCreateDescriptorSetLayout(textures)"); + + VkPipelineLayoutCreateInfo pipeline_layout{}; + pipeline_layout.sType = VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO; + pipeline_layout.setLayoutCount = static_cast(mDescriptorSetLayouts.size()); + pipeline_layout.pSetLayouts = mDescriptorSetLayouts.data(); + check(vkCreatePipelineLayout(mDevice, &pipeline_layout, nullptr, &mPipelineLayout), + "vkCreatePipelineLayout(material)"); + + std::array pool_sizes{}; + pool_sizes[0] = { VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER, 1 }; + pool_sizes[1] = { VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 3 }; + VkDescriptorPoolCreateInfo pool_info{}; + pool_info.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO; + pool_info.maxSets = 2; + pool_info.poolSizeCount = static_cast(pool_sizes.size()); + pool_info.pPoolSizes = pool_sizes.data(); + check(vkCreateDescriptorPool(mDevice, &pool_info, nullptr, &mDescriptorPool), "vkCreateDescriptorPool(material)"); + + VkDescriptorSetAllocateInfo allocation{}; + allocation.sType = VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO; + allocation.descriptorPool = mDescriptorPool; + allocation.descriptorSetCount = static_cast(mDescriptorSetLayouts.size()); + allocation.pSetLayouts = mDescriptorSetLayouts.data(); + check(vkAllocateDescriptorSets(mDevice, &allocation, mDescriptorSets.data()), "vkAllocateDescriptorSets(material)"); + + VkDescriptorBufferInfo buffer_info{}; + buffer_info.buffer = mParameterBuffer.mBuffer; + buffer_info.offset = 0; + buffer_info.range = sizeof(MaterialParameters); + std::array image_infos{}; + for (std::size_t index = 0; index < image_infos.size(); ++index) + { + image_infos[index].sampler = mSampler; + image_infos[index].imageView = mTextures[index].mView; + image_infos[index].imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + } + + std::array writes{}; + writes[0].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[0].dstSet = mDescriptorSets[0]; + writes[0].dstBinding = 0; + writes[0].descriptorCount = 1; + writes[0].descriptorType = VK_DESCRIPTOR_TYPE_UNIFORM_BUFFER; + writes[0].pBufferInfo = &buffer_info; + for (std::size_t index = 0; index < image_infos.size(); ++index) + { + writes[index + 1].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[index + 1].dstSet = mDescriptorSets[1]; + writes[index + 1].dstBinding = static_cast(index); + writes[index + 1].descriptorCount = 1; + writes[index + 1].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[index + 1].pImageInfo = &image_infos[index]; + } + vkUpdateDescriptorSets(mDevice, static_cast(writes.size()), writes.data(), 0, nullptr); +} + +void VulkanMaterialRun::createTargets() +{ + constexpr VkImageUsageFlags COLOR_USAGE = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT | + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT; + constexpr std::array COLOR_FORMATS{ VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_R16G16B16A16_UNORM }; + for (std::size_t index = 0; index < mColors.size(); ++index) + { + mColors[index] = createImage(COLOR_FORMATS[index], FRAME_EXTENT, 1, COLOR_USAGE, + VK_IMAGE_ASPECT_COLOR_BIT); + } + constexpr VkImageUsageFlags DEPTH_USAGE = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_DEPTH_STENCIL_ATTACHMENT_BIT; + mDepth = createImage(VK_FORMAT_D32_SFLOAT, FRAME_EXTENT, 1, DEPTH_USAGE, + VK_IMAGE_ASPECT_DEPTH_BIT); + + std::array attachments{}; + for (std::size_t index = 0; index < mColors.size(); ++index) + { + attachments[index].format = mColors[index].mFormat; + attachments[index].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[index].loadOp = VK_ATTACHMENT_LOAD_OP_CLEAR; + attachments[index].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[index].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[index].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[index].initialLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + attachments[index].finalLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + } + attachments[3].format = VK_FORMAT_D32_SFLOAT; + attachments[3].samples = VK_SAMPLE_COUNT_1_BIT; + attachments[3].loadOp = VK_ATTACHMENT_LOAD_OP_LOAD; + attachments[3].storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachments[3].stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachments[3].stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachments[3].initialLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + attachments[3].finalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + std::array color_references{}; + for (std::uint32_t index = 0; index < color_references.size(); ++index) + { + color_references[index].attachment = index; + color_references[index].layout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + } + VkAttachmentReference depth_reference{}; + depth_reference.attachment = 3; + depth_reference.layout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = static_cast(color_references.size()); + subpass.pColorAttachments = color_references.data(); + subpass.pDepthStencilAttachment = &depth_reference; + + constexpr VkPipelineStageFlags ATTACHMENT_STAGES = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | + VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + constexpr VkAccessFlags ATTACHMENT_ACCESS = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + std::array dependencies{}; + dependencies[0].srcSubpass = VK_SUBPASS_EXTERNAL; + dependencies[0].dstSubpass = 0; + dependencies[0].srcStageMask = ATTACHMENT_STAGES; + dependencies[0].dstStageMask = ATTACHMENT_STAGES; + dependencies[0].srcAccessMask = 0; + dependencies[0].dstAccessMask = ATTACHMENT_ACCESS; + dependencies[1].srcSubpass = 0; + dependencies[1].dstSubpass = VK_SUBPASS_EXTERNAL; + dependencies[1].srcStageMask = ATTACHMENT_STAGES; + dependencies[1].dstStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | + VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + dependencies[1].srcAccessMask = ATTACHMENT_ACCESS; + dependencies[1].dstAccessMask = VK_ACCESS_SHADER_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + + VkRenderPassCreateInfo render_pass_info{}; + render_pass_info.sType = VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO; + render_pass_info.attachmentCount = static_cast(attachments.size()); + render_pass_info.pAttachments = attachments.data(); + render_pass_info.subpassCount = 1; + render_pass_info.pSubpasses = &subpass; + render_pass_info.dependencyCount = static_cast(dependencies.size()); + render_pass_info.pDependencies = dependencies.data(); + check(vkCreateRenderPass(mDevice, &render_pass_info, nullptr, &mRenderPass), + "vkCreateRenderPass(material)"); + + std::array views{ mColors[0].mView, mColors[1].mView, mColors[2].mView, + mDepth.mView }; + VkFramebufferCreateInfo framebuffer_info{}; + framebuffer_info.sType = VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO; + framebuffer_info.renderPass = mRenderPass; + framebuffer_info.attachmentCount = static_cast(views.size()); + framebuffer_info.pAttachments = views.data(); + framebuffer_info.width = FRAME_EXTENT.mWidth; + framebuffer_info.height = FRAME_EXTENT.mHeight; + framebuffer_info.layers = 1; + check(vkCreateFramebuffer(mDevice, &framebuffer_info, nullptr, &mFramebuffer), + "vkCreateFramebuffer(material)"); +} + +VkShaderModule VulkanMaterialRun::createShaderModule(const std::vector& words) +{ + VkShaderModuleCreateInfo create_info{}; + create_info.sType = VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO; + create_info.codeSize = words.size() * sizeof(std::uint32_t); + create_info.pCode = words.data(); + VkShaderModule module = VK_NULL_HANDLE; + check(vkCreateShaderModule(mDevice, &create_info, nullptr, &module), "vkCreateShaderModule(material)"); + return module; +} + +void VulkanMaterialRun::createPipeline() +{ + VkShaderModule vertex_module = createShaderModule(mVertexSpirv); + VkShaderModule fragment_module = VK_NULL_HANDLE; + try + { + fragment_module = createShaderModule(mFragmentSpirv); + + std::array stages{}; + stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT; + stages[0].module = vertex_module; + stages[0].pName = "main"; + stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT; + stages[1].module = fragment_module; + stages[1].pName = "main"; + + std::array bindings{}; + std::array attributes{}; + for (std::uint32_t index = 0; index < bindings.size(); ++index) + { + bindings[index].binding = index; + bindings[index].stride = VERTEX_STRIDES[index]; + bindings[index].inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + attributes[index].location = index; + attributes[index].binding = index; + attributes[index].format = VERTEX_FORMATS[index]; + attributes[index].offset = 0; + } + VkPipelineVertexInputStateCreateInfo vertex_input{}; + vertex_input.sType = VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO; + vertex_input.vertexBindingDescriptionCount = static_cast(bindings.size()); + vertex_input.pVertexBindingDescriptions = bindings.data(); + vertex_input.vertexAttributeDescriptionCount = static_cast(attributes.size()); + vertex_input.pVertexAttributeDescriptions = attributes.data(); + + VkPipelineInputAssemblyStateCreateInfo input_assembly{}; + input_assembly.sType = VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO; + input_assembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + input_assembly.primitiveRestartEnable = VK_FALSE; + + VkPipelineViewportStateCreateInfo viewport{}; + viewport.sType = VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO; + viewport.viewportCount = 1; + viewport.scissorCount = 1; + + VkPipelineRasterizationStateCreateInfo raster{}; + raster.sType = VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO; + raster.depthClampEnable = VK_FALSE; + raster.rasterizerDiscardEnable = VK_FALSE; + raster.polygonMode = VK_POLYGON_MODE_FILL; + raster.cullMode = VK_CULL_MODE_BACK_BIT; + raster.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + raster.depthBiasEnable = VK_FALSE; + raster.lineWidth = 1.f; + + VkPipelineMultisampleStateCreateInfo multisample{}; + multisample.sType = VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO; + multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + multisample.sampleShadingEnable = VK_FALSE; + + VkPipelineDepthStencilStateCreateInfo depth{}; + depth.sType = VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO; + depth.depthTestEnable = VK_TRUE; + depth.depthWriteEnable = VK_TRUE; + depth.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + depth.depthBoundsTestEnable = VK_FALSE; + depth.stencilTestEnable = VK_FALSE; + depth.minDepthBounds = 0.f; + depth.maxDepthBounds = 1.f; + + std::array blend_attachments{}; + for (auto& attachment : blend_attachments) + { + attachment.blendEnable = VK_FALSE; + attachment.srcColorBlendFactor = VK_BLEND_FACTOR_ONE; + attachment.dstColorBlendFactor = VK_BLEND_FACTOR_ZERO; + attachment.colorBlendOp = VK_BLEND_OP_ADD; + attachment.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + attachment.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; + attachment.alphaBlendOp = VK_BLEND_OP_ADD; + attachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + } + VkPipelineColorBlendStateCreateInfo blend{}; + blend.sType = VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO; + blend.logicOpEnable = VK_FALSE; + blend.logicOp = VK_LOGIC_OP_COPY; + blend.attachmentCount = static_cast(blend_attachments.size()); + blend.pAttachments = blend_attachments.data(); + + constexpr std::array DYNAMIC_STATES{ VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR }; + VkPipelineDynamicStateCreateInfo dynamic{}; + dynamic.sType = VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO; + dynamic.dynamicStateCount = static_cast(DYNAMIC_STATES.size()); + dynamic.pDynamicStates = DYNAMIC_STATES.data(); + + VkGraphicsPipelineCreateInfo pipeline_info{}; + pipeline_info.sType = VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO; + pipeline_info.stageCount = static_cast(stages.size()); + pipeline_info.pStages = stages.data(); + pipeline_info.pVertexInputState = &vertex_input; + pipeline_info.pInputAssemblyState = &input_assembly; + pipeline_info.pViewportState = &viewport; + pipeline_info.pRasterizationState = &raster; + pipeline_info.pMultisampleState = &multisample; + pipeline_info.pDepthStencilState = &depth; + pipeline_info.pColorBlendState = &blend; + pipeline_info.pDynamicState = &dynamic; + pipeline_info.layout = mPipelineLayout; + pipeline_info.renderPass = mRenderPass; + pipeline_info.subpass = 0; + check(vkCreateGraphicsPipelines(mDevice, VK_NULL_HANDLE, 1, &pipeline_info, nullptr, &mPipeline), + "vkCreateGraphicsPipelines(material)"); + } + catch (...) + { + if (fragment_module != VK_NULL_HANDLE) + { + vkDestroyShaderModule(mDevice, fragment_module, nullptr); + } + vkDestroyShaderModule(mDevice, vertex_module, nullptr); + throw; + } + vkDestroyShaderModule(mDevice, fragment_module, nullptr); + vkDestroyShaderModule(mDevice, vertex_module, nullptr); +} + +LLRenderVulkanMaterial::ExecutionContext VulkanMaterialRun::executionContext() +{ + LLRenderVulkanMaterial::ExecutionContext context; + context.mDevice = mDevice; + context.mCommandBuffer = mCommandBuffer; + context.mQueue = mQueue; + context.mRecordingAttemptCount = &mExecutorRecordings; + context.mSubmissionCount = &mExecutorSubmissions; + context.mRequiredVertexShaderIdentity = mVertexIdentity; + context.mRequiredFragmentShaderIdentity = mFragmentIdentity; + return context; +} + +LLRenderVulkanMaterial::Registry VulkanMaterialRun::makeRegistry( + const MaterialCase& diagnostic_case, + RegistryMutation mutation) +{ + using namespace LLRenderVulkanMaterial; + const auto& handles = diagnostic_case.mInputs.mHandles; + Registry registry; + + auto buffer_binding = [](const Buffer& buffer, VkBufferUsageFlags usage) + { + BufferBinding binding; + binding.mBuffer = buffer.mBuffer; + binding.mSize = buffer.mSize; + binding.mUsage = usage; + return binding; + }; + BufferBinding vertex = buffer_binding(mVertexBuffer, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT); + BufferBinding index = buffer_binding(mIndexBuffer, VK_BUFFER_USAGE_INDEX_BUFFER_BIT); + index.mHasTranslatedIndices = true; + index.mTranslatedIndices = MATERIAL_VULKAN_INDICES; + if (mutation == RegistryMutation::WrongTranslatedIndices) + { + index.mTranslatedIndices = LLRenderContract::MATERIAL_INDICES; + } + if (!registry.addBuffer(mutation == RegistryMutation::StaleVertex ? stale(handles.mVertexBuffer) + : handles.mVertexBuffer, + vertex) || + !registry.addBuffer(mutation == RegistryMutation::StaleIndex ? stale(handles.mIndexBuffer) + : handles.mIndexBuffer, + index)) + { + throw Failure("cannot register material buffers"); + } + + auto image_binding = [](const Image& image) + { + ImageBinding binding; + binding.mImage = image.mImage; + binding.mView = image.mView; + binding.mFormat = image.mFormat; + binding.mExtent = image.mExtent; + binding.mMipLevels = image.mMipLevels; + binding.mUsage = image.mUsage; + binding.mAspect = image.mAspect; + binding.mLayout = image.mLayout; + binding.mViewRange.aspectMask = image.mAspect; + binding.mViewRange.baseMipLevel = 0; + binding.mViewRange.levelCount = image.mMipLevels; + binding.mViewRange.baseArrayLayer = 0; + binding.mViewRange.layerCount = 1; + return binding; + }; + std::array sources{}; + std::array colors{}; + for (std::size_t index_value = 0; index_value < sources.size(); ++index_value) + { + sources[index_value] = image_binding(mTextures[index_value]); + colors[index_value] = image_binding(mColors[index_value]); + } + ImageBinding depth = image_binding(mDepth); + + if (mutation == RegistryMutation::WrongTextureFormat) + sources[0].mFormat = VK_FORMAT_R8G8B8A8_SRGB; + if (mutation == RegistryMutation::WrongTextureExtent) + --sources[0].mExtent.mWidth; + if (mutation == RegistryMutation::WrongTextureMips) + --sources[0].mMipLevels; + if (mutation == RegistryMutation::MissingUsageBit) + colors[0].mUsage &= ~VK_IMAGE_USAGE_SAMPLED_BIT; + if (mutation == RegistryMutation::LiveLayoutDrift) + colors[0].mLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + if (mutation == RegistryMutation::ViewRangeDrift) + colors[0].mViewRange.baseMipLevel = 1; + if (mutation == RegistryMutation::WrongColorFormat) + colors[2].mFormat = VK_FORMAT_R8G8B8A8_UNORM; + if (mutation == RegistryMutation::WrongTargetExtent) + --colors[0].mExtent.mWidth; + if (mutation == RegistryMutation::WrongDepthFormat) + depth.mFormat = VK_FORMAT_D16_UNORM; + if (mutation == RegistryMutation::AttachmentAliasing) + { + colors[0].mImage = colors[1].mImage; + colors[0].mView = colors[1].mView; + } + + const std::array image_handles{ handles.mDiffuse, handles.mNormal, handles.mSpecular, + handles.mGBuffer0, handles.mGBuffer1, handles.mGBuffer2, + handles.mDepth }; + std::array image_bindings{ sources[0], sources[1], sources[2], + colors[0], colors[1], colors[2], depth }; + for (std::size_t index_value = 0; index_value < image_bindings.size(); ++index_value) + { + auto handle = image_handles[index_value]; + const bool stale_image = + (index_value == 0 && mutation == RegistryMutation::StaleDiffuse) || + (index_value == 1 && mutation == RegistryMutation::StaleNormal) || + (index_value == 2 && mutation == RegistryMutation::StaleSpecular) || + (index_value == 3 && mutation == RegistryMutation::StaleGBuffer0) || + (index_value == 4 && mutation == RegistryMutation::StaleGBuffer1) || + (index_value == 5 && mutation == RegistryMutation::StaleGBuffer2) || + (index_value == 6 && mutation == RegistryMutation::StaleDepth); + if (stale_image) + { + handle = stale(handle); + } + if (!registry.addImage(handle, image_bindings[index_value])) + { + throw Failure("cannot register material images"); + } + } + + SamplerBinding sampler; + sampler.mSampler = mSampler; + sampler.mMinFilter = VK_FILTER_LINEAR; + sampler.mMagFilter = VK_FILTER_LINEAR; + sampler.mMipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + sampler.mAddressU = VK_SAMPLER_ADDRESS_MODE_REPEAT; + sampler.mAddressV = VK_SAMPLER_ADDRESS_MODE_REPEAT; + sampler.mAddressW = VK_SAMPLER_ADDRESS_MODE_REPEAT; + sampler.mMipLodBias = 0.f; + sampler.mAnisotropyEnable = VK_TRUE; + sampler.mMaxAnisotropy = 8.f; + sampler.mCompareEnable = VK_FALSE; + sampler.mCompareOp = VK_COMPARE_OP_ALWAYS; + sampler.mMinLod = 0.f; + sampler.mMaxLod = 2.f; + sampler.mBorderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK; + sampler.mUnnormalizedCoordinates = VK_FALSE; + if (mutation == RegistryMutation::WrongSampler) + sampler.mMaxAnisotropy = 4.f; + if (!registry.addSampler(mutation == RegistryMutation::StaleSampler ? stale(handles.mSampler) + : handles.mSampler, + sampler)) + { + throw Failure("cannot register material sampler"); + } + + PipelineBinding pipeline; + pipeline.mProgram = diagnostic_case.mFrame.mPipelines.front().mProgram; + pipeline.mPipeline = mPipeline; + pipeline.mLayout = mPipelineLayout; + pipeline.mRenderPass = mRenderPass; + pipeline.mFramebuffer = mFramebuffer; + pipeline.mExtent = FRAME_EXTENT; + pipeline.mDescriptorSets = mDescriptorSets; + pipeline.mParameterDescriptor.mSet = 0; + pipeline.mParameterDescriptor.mBinding = 0; + pipeline.mParameterDescriptor.mBuffer = mParameterBuffer.mBuffer; + pipeline.mParameterDescriptor.mOffset = 0; + pipeline.mParameterDescriptor.mRange = sizeof(MaterialParameters); + pipeline.mParameters.mBuffer = mParameterBuffer.mBuffer; + pipeline.mParameters.mUsage = VK_BUFFER_USAGE_UNIFORM_BUFFER_BIT; + pipeline.mParameters.mSize = mParameterBuffer.mSize; + pipeline.mParameters.mMemory = mParameterBuffer.mMemory; + pipeline.mParameters.mMapped = mParameterBuffer.mMapped; + pipeline.mParameters.mAllocationSize = mParameterBuffer.mAllocationSize; + pipeline.mParameters.mDescriptorOffset = 0; + pipeline.mParameters.mDescriptorRange = sizeof(MaterialParameters); + pipeline.mParameters.mMemoryProperties = mParameterBuffer.mMemoryProperties; + for (std::uint32_t index_value = 0; index_value < mTextures.size(); ++index_value) + { + pipeline.mSampledDescriptors[index_value].mSet = 1; + pipeline.mSampledDescriptors[index_value].mBinding = index_value; + pipeline.mSampledDescriptors[index_value].mView = mTextures[index_value].mView; + pipeline.mSampledDescriptors[index_value].mSampler = mSampler; + pipeline.mSampledDescriptors[index_value].mLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + pipeline.mColorViews[index_value] = mColors[index_value].mView; + pipeline.mColorLoadOps[index_value] = VK_ATTACHMENT_LOAD_OP_CLEAR; + pipeline.mColorStoreOps[index_value] = VK_ATTACHMENT_STORE_OP_STORE; + pipeline.mVertexBindings[index_value].mBinding = index_value; + } + for (std::uint32_t index_value = 0; index_value < pipeline.mVertexBindings.size(); ++index_value) + { + pipeline.mVertexBindings[index_value].mBinding = index_value; + pipeline.mVertexBindings[index_value].mStride = VERTEX_STRIDES[index_value]; + pipeline.mVertexBindings[index_value].mInputRate = VK_VERTEX_INPUT_RATE_VERTEX; + pipeline.mVertexAttributes[index_value].mLocation = index_value; + pipeline.mVertexAttributes[index_value].mBinding = index_value; + pipeline.mVertexAttributes[index_value].mFormat = VERTEX_FORMATS[index_value]; + pipeline.mVertexAttributes[index_value].mOffset = 0; + } + pipeline.mDepthView = mDepth.mView; + pipeline.mDepthFormat = VK_FORMAT_D32_SFLOAT; + pipeline.mDepthLoadOp = VK_ATTACHMENT_LOAD_OP_LOAD; + pipeline.mDepthStoreOp = VK_ATTACHMENT_STORE_OP_STORE; + pipeline.mColorInitialLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + pipeline.mColorFinalLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + pipeline.mDepthInitialLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + pipeline.mDepthFinalLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + pipeline.mSubpassDependencies[0].mSourceSubpass = VK_SUBPASS_EXTERNAL; + pipeline.mSubpassDependencies[0].mDestinationSubpass = 0; + pipeline.mSubpassDependencies[0].mSourceStages = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT | + VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + pipeline.mSubpassDependencies[0].mDestinationStages = + pipeline.mSubpassDependencies[0].mSourceStages; + pipeline.mSubpassDependencies[0].mSourceAccess = 0; + pipeline.mSubpassDependencies[0].mDestinationAccess = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + pipeline.mSubpassDependencies[1].mSourceSubpass = 0; + pipeline.mSubpassDependencies[1].mDestinationSubpass = VK_SUBPASS_EXTERNAL; + pipeline.mSubpassDependencies[1].mSourceStages = pipeline.mSubpassDependencies[0].mSourceStages; + pipeline.mSubpassDependencies[1].mDestinationStages = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | + VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + pipeline.mSubpassDependencies[1].mSourceAccess = + pipeline.mSubpassDependencies[0].mDestinationAccess; + pipeline.mSubpassDependencies[1].mDestinationAccess = VK_ACCESS_SHADER_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + pipeline.mIndexType = VK_INDEX_TYPE_UINT16; + pipeline.mRaster.mTopology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + pipeline.mRaster.mPrimitiveRestartEnable = VK_FALSE; + pipeline.mRaster.mDepthClampEnable = VK_FALSE; + pipeline.mRaster.mRasterizerDiscardEnable = VK_FALSE; + pipeline.mRaster.mPolygonMode = VK_POLYGON_MODE_FILL; + pipeline.mRaster.mCullMode = VK_CULL_MODE_BACK_BIT; + pipeline.mRaster.mFrontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + pipeline.mRaster.mDepthBiasEnable = VK_FALSE; + pipeline.mRaster.mLineWidth = 1.f; + pipeline.mMultisample.mRasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + pipeline.mDepthStencil.mDepthTestEnable = VK_TRUE; + pipeline.mDepthStencil.mDepthWriteEnable = VK_TRUE; + pipeline.mDepthStencil.mDepthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + pipeline.mDepthStencil.mMinDepthBounds = 0.f; + pipeline.mDepthStencil.mMaxDepthBounds = 1.f; + constexpr std::array COLOR_FORMATS{ VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_R16G16B16A16_UNORM }; + for (std::size_t index_value = 0; index_value < pipeline.mColorTargets.size(); ++index_value) + { + auto& target = pipeline.mColorTargets[index_value]; + target.mFormat = COLOR_FORMATS[index_value]; + target.mBlendEnable = VK_FALSE; + target.mSrcColorBlendFactor = VK_BLEND_FACTOR_ONE; + target.mDstColorBlendFactor = VK_BLEND_FACTOR_ZERO; + target.mColorBlendOp = VK_BLEND_OP_ADD; + target.mSrcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + target.mDstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; + target.mAlphaBlendOp = VK_BLEND_OP_ADD; + target.mWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + } + pipeline.mLogicOpEnable = VK_FALSE; + pipeline.mLogicOp = VK_LOGIC_OP_COPY; + pipeline.mDynamicViewport = VK_TRUE; + pipeline.mDynamicScissor = VK_TRUE; + pipeline.mVertexShaderIdentity = mVertexIdentity; + pipeline.mFragmentShaderIdentity = mFragmentIdentity; + + if (mutation == RegistryMutation::WrongProgram) + pipeline.mProgram.mName = "deferred.material.wrong"; + if (mutation == RegistryMutation::WrongVariant) + ++pipeline.mProgram.mVariant; + if (mutation == RegistryMutation::WrongVertexLayout) + ++pipeline.mVertexBindings[0].mStride; + if (mutation == RegistryMutation::WrongDescriptors) + std::swap(pipeline.mSampledDescriptors[0].mView, pipeline.mSampledDescriptors[1].mView); + if (mutation == RegistryMutation::WrongParameterRange) + --pipeline.mParameters.mDescriptorRange; + if (mutation == RegistryMutation::WrongParameterOffset) + pipeline.mParameters.mDescriptorOffset = 4; + if (mutation == RegistryMutation::WrongAttachmentView) + pipeline.mColorViews[0] = mColors[1].mView; + if (mutation == RegistryMutation::WrongIndexType) + pipeline.mIndexType = VK_INDEX_TYPE_UINT32; + if (mutation == RegistryMutation::WrongTopology) + pipeline.mRaster.mTopology = VK_PRIMITIVE_TOPOLOGY_LINE_LIST; + if (mutation == RegistryMutation::WrongRenderPassMetadata) + pipeline.mColorInitialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + if (mutation == RegistryMutation::WrongPipelineMetadata) + pipeline.mLogicOp = VK_LOGIC_OP_CLEAR; + if (mutation == RegistryMutation::WrongShaderIdentity) + pipeline.mVertexShaderIdentity[0] ^= 0xffU; + + if (!registry.addPipeline(mutation == RegistryMutation::StalePipeline ? stale(handles.mPipeline) + : handles.mPipeline, + std::move(pipeline))) + { + throw Failure("cannot register material pipeline"); + } + return registry; +} + +void VulkanMaterialRun::mutateFrame(FrameSnapshot& frame, FrameMutation mutation) const +{ + if (mutation == FrameMutation::None) + { + return; + } + auto& pass = frame.mPasses.front(); + auto& draw = std::get(pass.mDraws.front()); + switch (mutation) + { + case FrameMutation::WrongLayout: + frame.mPipelines.front().mVertexBindings.front().mStride = 12; + break; + case FrameMutation::WrongImageRange: + draw.mResources.mSampledImages.front().mRange.mMipLevelCount = + LLRenderContract::MATERIAL_TEXTURE_MIP_LEVELS - 1; + break; + case FrameMutation::WrongIndexRange: + draw.mFirstIndex = 1; + break; + case FrameMutation::WrongParameterSize: + --draw.mResources.mParameters.front().mBytes.mSize; + break; + case FrameMutation::None: + break; + } +} + +void VulkanMaterialRun::seedImage(Image& image, const std::vector& bytes, + VkImageLayout final_layout, VkAccessFlags final_access, + VkPipelineStageFlags final_stage) +{ + VkDeviceSize expected_size = 0; + switch (image.mFormat) + { + case VK_FORMAT_R8G8B8A8_UNORM: + expected_size = RGBA8_BYTES; + break; + case VK_FORMAT_R16G16B16A16_UNORM: + expected_size = RGBA16_BYTES; + break; + case VK_FORMAT_D32_SFLOAT: + expected_size = DEPTH32_BYTES; + break; + default: + throw Failure("cannot seed an unsupported material target format"); + } + if (bytes.size() != expected_size) + { + throw Failure("material target seed has the wrong byte count"); + } + + VkAccessFlags source_access = 0; + VkPipelineStageFlags source_stage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; + switch (image.mLayout) + { + case VK_IMAGE_LAYOUT_UNDEFINED: + break; + case VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL: + source_access = VK_ACCESS_TRANSFER_READ_BIT; + source_stage = VK_PIPELINE_STAGE_TRANSFER_BIT; + break; + case VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL: + source_access = VK_ACCESS_SHADER_READ_BIT; + source_stage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + break; + case VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL: + source_access = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + source_stage = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + break; + case VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL: + source_access = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + source_stage = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + break; + default: + throw Failure("material target has an untracked layout before seed"); + } + + writeBuffer(mTransferBuffer, bytes.data(), bytes.size()); + const VkImageLayout old_layout = image.mLayout; + submitImmediate([this, &image, expected_size, old_layout, source_access, source_stage, + final_layout, final_access, final_stage](VkCommandBuffer command_buffer) + { + VkBufferMemoryBarrier host_to_transfer{}; + host_to_transfer.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; + host_to_transfer.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT; + host_to_transfer.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + host_to_transfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host_to_transfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host_to_transfer.buffer = mTransferBuffer.mBuffer; + host_to_transfer.offset = 0; + host_to_transfer.size = expected_size; + + VkImageMemoryBarrier to_transfer{}; + to_transfer.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + to_transfer.srcAccessMask = source_access; + to_transfer.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + to_transfer.oldLayout = old_layout; + to_transfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + to_transfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_transfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_transfer.image = image.mImage; + to_transfer.subresourceRange.aspectMask = image.mAspect; + to_transfer.subresourceRange.baseMipLevel = 0; + to_transfer.subresourceRange.levelCount = 1; + to_transfer.subresourceRange.baseArrayLayer = 0; + to_transfer.subresourceRange.layerCount = 1; + vkCmdPipelineBarrier(command_buffer, source_stage | VK_PIPELINE_STAGE_HOST_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, + 1, &host_to_transfer, 1, &to_transfer); + + VkBufferImageCopy copy{}; + copy.imageSubresource.aspectMask = image.mAspect; + copy.imageSubresource.mipLevel = 0; + copy.imageSubresource.baseArrayLayer = 0; + copy.imageSubresource.layerCount = 1; + copy.imageExtent = { image.mExtent.mWidth, image.mExtent.mHeight, 1 }; + vkCmdCopyBufferToImage(command_buffer, mTransferBuffer.mBuffer, image.mImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©); + + VkImageMemoryBarrier to_final = to_transfer; + to_final.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + to_final.dstAccessMask = final_access; + to_final.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + to_final.newLayout = final_layout; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, final_stage, + 0, 0, nullptr, 0, nullptr, 1, &to_final); + }); + image.mLayout = final_layout; +} + +void VulkanMaterialRun::seedTargets(const MaterialFixture& fixture) +{ + auto bottom_to_top_bytes = [](const auto& source) + { + using Value = typename std::decay_t::value_type; + constexpr std::size_t channels = LLRenderContract::MATERIAL_DIAGNOSTIC_CHANNELS; + constexpr std::size_t width = LLRenderContract::MATERIAL_FRAME_WIDTH; + constexpr std::size_t height = LLRenderContract::MATERIAL_FRAME_HEIGHT; + constexpr std::size_t size = std::tuple_size_v>; + std::array result{}; + for (std::size_t top_row = 0; top_row < height; ++top_row) + { + const std::size_t bottom_row = height - 1 - top_row; + std::copy_n(source.begin() + bottom_row * width * channels, width * channels, + result.begin() + top_row * width * channels); + } + std::vector bytes(sizeof(result)); + std::memcpy(bytes.data(), result.data(), sizeof(result)); + return bytes; + }; + + seedImage(mColors[0], bottom_to_top_bytes(fixture.mGBuffer0SentinelRGBA8), + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_TRANSFER_READ_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT); + seedImage(mColors[1], bottom_to_top_bytes(fixture.mGBuffer1SentinelRGBA8), + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_TRANSFER_READ_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT); + seedImage(mColors[2], bottom_to_top_bytes(fixture.mGBuffer2SentinelRGBA16), + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, VK_ACCESS_TRANSFER_READ_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT); + + std::array depth{}; + for (std::size_t top_row = 0; top_row < FRAME_EXTENT.mHeight; ++top_row) + { + const std::size_t bottom_row = FRAME_EXTENT.mHeight - 1 - top_row; + for (std::size_t x = 0; x < FRAME_EXTENT.mWidth; ++x) + { + depth[top_row * FRAME_EXTENT.mWidth + x] = + LLRenderContract::materialDepth24(fixture.mDepth24[bottom_row * FRAME_EXTENT.mWidth + x]); + } + } + std::vector depth_bytes(sizeof(depth)); + std::memcpy(depth_bytes.data(), depth.data(), sizeof(depth)); + seedImage(mDepth, depth_bytes, VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL, + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT, + VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT); +} + +std::vector VulkanMaterialRun::readImage(Image& image) +{ + VkDeviceSize byte_count = 0; + switch (image.mFormat) + { + case VK_FORMAT_R8G8B8A8_UNORM: + byte_count = RGBA8_BYTES; + break; + case VK_FORMAT_R16G16B16A16_UNORM: + byte_count = RGBA16_BYTES; + break; + case VK_FORMAT_D32_SFLOAT: + byte_count = DEPTH32_BYTES; + break; + default: + throw Failure("cannot read an unsupported material target format"); + } + + VkAccessFlags source_access = 0; + VkPipelineStageFlags source_stage = VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT; + switch (image.mLayout) + { + case VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL: + source_access = VK_ACCESS_TRANSFER_READ_BIT; + source_stage = VK_PIPELINE_STAGE_TRANSFER_BIT; + break; + case VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL: + source_access = VK_ACCESS_SHADER_READ_BIT; + source_stage = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + break; + case VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL: + source_access = VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_READ_BIT | + VK_ACCESS_DEPTH_STENCIL_ATTACHMENT_WRITE_BIT; + source_stage = VK_PIPELINE_STAGE_EARLY_FRAGMENT_TESTS_BIT | + VK_PIPELINE_STAGE_LATE_FRAGMENT_TESTS_BIT; + break; + default: + throw Failure("material target has an untracked layout before readback"); + } + const VkImageLayout old_layout = image.mLayout; + submitImmediate([this, &image, byte_count, old_layout, source_access, source_stage] + (VkCommandBuffer command_buffer) + { + VkBufferMemoryBarrier prepare_buffer{}; + prepare_buffer.sType = VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER; + prepare_buffer.srcAccessMask = VK_ACCESS_HOST_READ_BIT | VK_ACCESS_HOST_WRITE_BIT; + prepare_buffer.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + prepare_buffer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + prepare_buffer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + prepare_buffer.buffer = mTransferBuffer.mBuffer; + prepare_buffer.offset = 0; + prepare_buffer.size = byte_count; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_HOST_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, 0, nullptr, + 1, &prepare_buffer, 0, nullptr); + + VkImageMemoryBarrier to_transfer{}; + if (old_layout != VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL) + { + to_transfer.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + to_transfer.srcAccessMask = source_access; + to_transfer.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + to_transfer.oldLayout = old_layout; + to_transfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + to_transfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_transfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_transfer.image = image.mImage; + to_transfer.subresourceRange.aspectMask = image.mAspect; + to_transfer.subresourceRange.baseMipLevel = 0; + to_transfer.subresourceRange.levelCount = 1; + to_transfer.subresourceRange.baseArrayLayer = 0; + to_transfer.subresourceRange.layerCount = 1; + vkCmdPipelineBarrier(command_buffer, source_stage, VK_PIPELINE_STAGE_TRANSFER_BIT, + 0, 0, nullptr, 0, nullptr, 1, &to_transfer); + } + + VkBufferImageCopy copy{}; + copy.imageSubresource.aspectMask = image.mAspect; + copy.imageSubresource.mipLevel = 0; + copy.imageSubresource.baseArrayLayer = 0; + copy.imageSubresource.layerCount = 1; + copy.imageExtent = { image.mExtent.mWidth, image.mExtent.mHeight, 1 }; + vkCmdCopyImageToBuffer(command_buffer, image.mImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + mTransferBuffer.mBuffer, 1, ©); + + VkBufferMemoryBarrier to_host = prepare_buffer; + to_host.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + to_host.dstAccessMask = VK_ACCESS_HOST_READ_BIT; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_HOST_BIT, 0, 0, nullptr, + 1, &to_host, 0, nullptr); + + if (old_layout != VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL) + { + VkImageMemoryBarrier restore = to_transfer; + restore.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + restore.dstAccessMask = source_access; + restore.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + restore.newLayout = old_layout; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, source_stage, + 0, 0, nullptr, 0, nullptr, 1, &restore); + } + }); + + std::vector result(static_cast(byte_count)); + std::memcpy(result.data(), mTransferBuffer.mMapped, result.size()); + return result; +} + +std::array, 4> VulkanMaterialRun::targetSnapshot() +{ + return { readImage(mColors[0]), readImage(mColors[1]), readImage(mColors[2]), readImage(mDepth) }; +} + +void VulkanMaterialRun::runRejections(const MaterialCase& diagnostic_case, + const MaterialFixture& fixture) +{ + std::array parameter_sentinel{}; + parameter_sentinel.fill(0xa5U); + for (const RejectionSpec& rejection : REJECTIONS) + { + seedTargets(fixture); + writeBuffer(mParameterBuffer, parameter_sentinel.data(), parameter_sentinel.size()); + const auto targets_before = targetSnapshot(); + const auto parameters_before = readBuffer(mParameterBuffer, parameter_sentinel.size()); + const std::uint64_t recordings_before = mExecutorRecordings; + const std::uint64_t submissions_before = mExecutorSubmissions; + const std::uint32_t validation_before = validationMessageCount(); + + FrameSnapshot frame = diagnostic_case.mFrame; + mutateFrame(frame, rejection.mFrameMutation); + LLRenderVulkanMaterial::Registry registry = makeRegistry(diagnostic_case, rejection.mRegistryMutation); + LLRenderVulkanMaterial::ExecutionContext context = executionContext(); + std::string error; + const bool accepted = LLRenderVulkanMaterial::execute(frame, registry, context, error); + + const auto parameters_after = readBuffer(mParameterBuffer, parameter_sentinel.size()); + const auto targets_after = targetSnapshot(); + if (accepted || error.empty() || mExecutorRecordings != recordings_before || + mExecutorSubmissions != submissions_before || parameters_after != parameters_before || + targets_after != targets_before || validationMessageCount() != validation_before) + { + throw Failure(std::string("fail-closed rejection failed: ") + rejection.mName); + } + ++mRejectionCount; + } +} + +MaterialReadback VulkanMaterialRun::readTargets() +{ + MaterialReadback result; + const auto gbuffer0 = readImage(mColors[0]); + const auto gbuffer1 = readImage(mColors[1]); + const auto gbuffer2 = readImage(mColors[2]); + const auto depth = readImage(mDepth); + + std::array native0{}; + std::array native1{}; + std::array native2{}; + std::array native_depth{}; + std::memcpy(native0.data(), gbuffer0.data(), gbuffer0.size()); + std::memcpy(native1.data(), gbuffer1.data(), gbuffer1.size()); + std::memcpy(native2.data(), gbuffer2.data(), gbuffer2.size()); + std::memcpy(native_depth.data(), depth.data(), depth.size()); + + constexpr std::size_t color_row = LLRenderContract::MATERIAL_FRAME_WIDTH * + LLRenderContract::MATERIAL_DIAGNOSTIC_CHANNELS; + for (std::size_t bottom_row = 0; bottom_row < FRAME_EXTENT.mHeight; ++bottom_row) + { + const std::size_t top_row = FRAME_EXTENT.mHeight - 1 - bottom_row; + std::copy_n(native0.begin() + top_row * color_row, color_row, + result.mGBuffer0.begin() + bottom_row * color_row); + std::copy_n(native1.begin() + top_row * color_row, color_row, + result.mGBuffer1.begin() + bottom_row * color_row); + std::copy_n(native2.begin() + top_row * color_row, color_row, + result.mGBuffer2.begin() + bottom_row * color_row); + for (std::size_t x = 0; x < FRAME_EXTENT.mWidth; ++x) + { + const float value = native_depth[top_row * FRAME_EXTENT.mWidth + x]; + if (!std::isfinite(value) || value < 0.f || value > 1.f) + { + throw Failure("D32 material readback is outside normalized depth range"); + } + constexpr double DEPTH24_MAX = 16777215.0; + const auto code = static_cast(std::llround(static_cast(value) * DEPTH24_MAX)); + result.mDepth[bottom_row * FRAME_EXTENT.mWidth + x] = code; + } + } + return result; +} + +MaterialArtifact VulkanMaterialRun::artifactFrom(const MaterialReadback& readback) const +{ + MaterialArtifact artifact = LLRenderContract::makeMaterialArtifact(); + artifact.mGBuffer0RGBA8.reserve(readback.mGBuffer0.size()); + artifact.mGBuffer1RGBA8.reserve(readback.mGBuffer1.size()); + artifact.mGBuffer2RGBA16.reserve(readback.mGBuffer2.size()); + artifact.mDepth24.reserve(readback.mDepth.size()); + std::transform(readback.mGBuffer0.begin(), readback.mGBuffer0.end(), + std::back_inserter(artifact.mGBuffer0RGBA8), LLRenderContract::materialUnorm8); + std::transform(readback.mGBuffer1.begin(), readback.mGBuffer1.end(), + std::back_inserter(artifact.mGBuffer1RGBA8), LLRenderContract::materialUnorm8); + std::transform(readback.mGBuffer2.begin(), readback.mGBuffer2.end(), + std::back_inserter(artifact.mGBuffer2RGBA16), LLRenderContract::materialUnorm16); + std::transform(readback.mDepth.begin(), readback.mDepth.end(), + std::back_inserter(artifact.mDepth24), LLRenderContract::materialDepth24); + return artifact; +} + +template +bool hasDistinctWrittenPixels(const std::array& values, + const std::array& sentinel, + const std::array& depth, + const std::array& depth_sentinel) +{ + static_assert(Size % LLRenderContract::MATERIAL_DIAGNOSTIC_CHANNELS == 0); + static_assert(Size / LLRenderContract::MATERIAL_DIAGNOSTIC_CHANNELS == DepthSize); + std::size_t first_pixel = DepthSize; + bool changed_from_sentinel = false; + bool distinct = false; + for (std::size_t pixel = 0; pixel < DepthSize; ++pixel) + { + if (depth[pixel] == depth_sentinel[pixel]) + { + continue; + } + if (first_pixel == DepthSize) + { + first_pixel = pixel; + } + for (std::size_t channel = 0; channel < LLRenderContract::MATERIAL_DIAGNOSTIC_CHANNELS; ++channel) + { + const std::size_t component = + pixel * LLRenderContract::MATERIAL_DIAGNOSTIC_CHANNELS + channel; + const std::size_t first_component = + first_pixel * LLRenderContract::MATERIAL_DIAGNOSTIC_CHANNELS + channel; + changed_from_sentinel = changed_from_sentinel || values[component] != sentinel[component]; + distinct = distinct || values[component] != values[first_component]; + } + } + return changed_from_sentinel && distinct; +} + +DepthGate VulkanMaterialRun::verifyDepthGate(const MaterialFixture& fixture, + const MaterialReadback& readback) const +{ + struct Vertex + { + double mX; + double mY; + double mZ; + double mReciprocalW; + std::array mViewOverW; + }; + std::array positions{}; + std::memcpy(positions.data(), fixture.mVertexBytes.data() + LLRenderContract::MATERIAL_POSITION_OFFSET, + sizeof(positions)); + std::array vertices{}; + auto transform = [](const std::array& matrix, const float* value) + { + std::array result{}; + for (std::size_t row = 0; row < result.size(); ++row) + { + for (std::size_t column = 0; column < result.size(); ++column) + { + result[row] += static_cast(matrix[column * 4 + row]) * value[column]; + } + } + return result; + }; + for (std::size_t vertex = 0; vertex < vertices.size(); ++vertex) + { + const float* position = positions.data() + vertex * 4; + const std::array clip = + transform(fixture.mParameters.mModelviewProjectionMatrix, position); + const std::array view = transform(fixture.mParameters.mModelviewMatrix, position); + const double reciprocal_w = 1.0 / clip[3]; + vertices[vertex] = { + (clip[0] * reciprocal_w * 0.5 + 0.5) * LLRenderContract::MATERIAL_FRAME_WIDTH, + (clip[1] * reciprocal_w * 0.5 + 0.5) * LLRenderContract::MATERIAL_FRAME_HEIGHT, + clip[2] * reciprocal_w * 0.5 + 0.5, + reciprocal_w, + { view[0] * reciprocal_w, view[1] * reciprocal_w, view[2] * reciprocal_w } + }; + } + + auto cross = [](const Vertex& first, const Vertex& second, double x, double y) + { + return (second.mX - first.mX) * (y - first.mY) - + (second.mY - first.mY) * (x - first.mX); + }; + + DepthGate gate; + for (std::uint32_t y = 0; y < LLRenderContract::MATERIAL_FRAME_HEIGHT; ++y) + { + for (std::uint32_t x = 0; x < LLRenderContract::MATERIAL_FRAME_WIDTH; ++x) + { + const double sample_x = x + 0.5; + const double sample_y = y + 0.5; + bool covered = false; + double fragment_depth = 0.0; + for (std::size_t triangle = 0; triangle < fixture.mIndices.size(); triangle += 3) + { + const Vertex& a = vertices[fixture.mIndices[triangle]]; + const Vertex& b = vertices[fixture.mIndices[triangle + 1]]; + const Vertex& c = vertices[fixture.mIndices[triangle + 2]]; + const double area = cross(a, b, c.mX, c.mY); + const double wa = cross(b, c, sample_x, sample_y) / area; + const double wb = cross(c, a, sample_x, sample_y) / area; + const double wc = 1.0 - wa - wb; + if (std::min({ wa, wb, wc }) <= 0.08) + { + continue; + } + fragment_depth = wa * a.mZ + wb * b.mZ + wc * c.mZ; + const double reciprocal_w = + wa * a.mReciprocalW + wb * b.mReciprocalW + wc * c.mReciprocalW; + std::array view_position{}; + for (std::size_t component = 0; component < view_position.size(); ++component) + { + view_position[component] = + (wa * a.mViewOverW[component] + wb * b.mViewOverW[component] + + wc * c.mViewOverW[component]) / + reciprocal_w; + } + const auto& clip_plane = fixture.mParameters.mClipPlane; + const double clip_distance = view_position[0] * clip_plane[0] + + view_position[1] * clip_plane[1] + + view_position[2] * clip_plane[2] + clip_plane[3]; + if (fixture.mParameters.mMirror > 0.f && clip_distance < 0.0) + { + const std::size_t pixel = + static_cast(y) * LLRenderContract::MATERIAL_FRAME_WIDTH + x; + const double loaded_depth = LLRenderContract::materialDepth24(fixture.mDepth24[pixel]); + if (std::abs(fragment_depth - loaded_depth) >= 0.02 && + fragment_depth <= loaded_depth) + { + if (readback.mDepth[pixel] != fixture.mDepth24[pixel]) + { + return gate; + } + ++gate.mMirrorClippedPasses; + } + covered = false; + break; + } + covered = true; + break; + } + if (!covered) + { + continue; + } + + const std::size_t pixel = + static_cast(y) * LLRenderContract::MATERIAL_FRAME_WIDTH + x; + const double loaded_depth = LLRenderContract::materialDepth24(fixture.mDepth24[pixel]); + if (std::abs(fragment_depth - loaded_depth) < 0.02) + { + continue; + } + const bool expected_pass = fragment_depth <= loaded_depth; + const bool depth_changed = readback.mDepth[pixel] != fixture.mDepth24[pixel]; + if (expected_pass != depth_changed) + { + return gate; + } + if (expected_pass) + { + ++gate.mPasses; + } + else + { + ++gate.mFailures; + } + } + } + gate.mValid = gate.mPasses > 0 && gate.mFailures > 0 && gate.mMirrorClippedPasses > 0; + return gate; +} + +bool VulkanMaterialRun::nontrivialOutput(const MaterialFixture& fixture, + const MaterialReadback& readback, + DepthGate& gate) const +{ + gate = verifyDepthGate(fixture, readback); + return readback.mGBuffer0 != fixture.mGBuffer0SentinelRGBA8 && + readback.mGBuffer1 != fixture.mGBuffer1SentinelRGBA8 && + readback.mGBuffer2 != fixture.mGBuffer2SentinelRGBA16 && + hasDistinctWrittenPixels(readback.mGBuffer0, fixture.mGBuffer0SentinelRGBA8, + readback.mDepth, fixture.mDepth24) && + hasDistinctWrittenPixels(readback.mGBuffer1, fixture.mGBuffer1SentinelRGBA8, + readback.mDepth, fixture.mDepth24) && + hasDistinctWrittenPixels(readback.mGBuffer2, fixture.mGBuffer2SentinelRGBA16, + readback.mDepth, fixture.mDepth24) && + gate.mValid; +} + +MaterialArtifact VulkanMaterialRun::run() +{ + loadShaderFiles(); + createInstance(); + selectPhysicalDevice(); + createDevice(); + createCommandResources(); + + const MaterialFixture fixture = LLRenderContract::makeMaterialFixture(); + const MaterialCase diagnostic_case = LLRenderContract::makeMaterialCase(); + if (diagnostic_case.mInputs.mParameters != fixture.mParameters) + { + throw Failure("material case and fixture parameters differ"); + } + createFixtureBuffers(fixture); + createTextures(fixture); + createSampler(); + createDescriptorResources(); + createTargets(); + createPipeline(); + + runRejections(diagnostic_case, fixture); + if (mRejectionCount != REJECTIONS.size() || mExecutorRecordings != 0 || mExecutorSubmissions != 0) + { + throw Failure("material rejection matrix did not remain pre-recording"); + } + + seedTargets(fixture); + std::array parameter_sentinel{}; + parameter_sentinel.fill(0xa5U); + writeBuffer(mParameterBuffer, parameter_sentinel.data(), parameter_sentinel.size()); + + LLRenderVulkanMaterial::Registry registry = makeRegistry(diagnostic_case, RegistryMutation::None); + LLRenderVulkanMaterial::ExecutionContext context = executionContext(); + std::string execution_error; + if (!LLRenderVulkanMaterial::execute(diagnostic_case.mFrame, registry, context, execution_error)) + { + throw Failure("valid material replay was rejected: " + execution_error); + } + for (Image& color : mColors) + { + color.mLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + } + mDepth.mLayout = VK_IMAGE_LAYOUT_DEPTH_STENCIL_ATTACHMENT_OPTIMAL; + if (!execution_error.empty() || mExecutorRecordings != 1 || mExecutorSubmissions != 1) + { + throw Failure("valid material replay did not record and submit exactly once"); + } + + std::array expected_parameters{}; + std::memcpy(expected_parameters.data(), &fixture.mParameters, sizeof(fixture.mParameters)); + const auto actual_parameters = readBuffer(mParameterBuffer, expected_parameters.size()); + if (!std::equal(actual_parameters.begin(), actual_parameters.end(), expected_parameters.begin())) + { + throw Failure("valid material replay did not upload the exact 272-byte parameter packet"); + } + + const MaterialReadback readback = readTargets(); + if (!nontrivialOutput(fixture, readback, mDepthGate)) + { + const bool gbuffer0_distinct = hasDistinctWrittenPixels( + readback.mGBuffer0, fixture.mGBuffer0SentinelRGBA8, readback.mDepth, fixture.mDepth24); + const bool gbuffer1_distinct = hasDistinctWrittenPixels( + readback.mGBuffer1, fixture.mGBuffer1SentinelRGBA8, readback.mDepth, fixture.mDepth24); + const bool gbuffer2_distinct = hasDistinctWrittenPixels( + readback.mGBuffer2, fixture.mGBuffer2SentinelRGBA16, readback.mDepth, fixture.mDepth24); + const auto nonzero0 = std::count_if(readback.mGBuffer0.begin(), readback.mGBuffer0.end(), + [](auto value) { return value != 0; }); + const auto nonzero1 = std::count_if(readback.mGBuffer1.begin(), readback.mGBuffer1.end(), + [](auto value) { return value != 0; }); + const auto nonzero2 = std::count_if(readback.mGBuffer2.begin(), readback.mGBuffer2.end(), + [](auto value) { return value != 0; }); + std::size_t depth_changed = 0; + for (std::size_t pixel = 0; pixel < readback.mDepth.size(); ++pixel) + { + depth_changed += readback.mDepth[pixel] != fixture.mDepth24[pixel] ? 1U : 0U; + } + std::ostringstream detail; + detail << "material nontrivial-output gate failed" + << "; g0_changed=" << (readback.mGBuffer0 != fixture.mGBuffer0SentinelRGBA8) + << " g0_distinct=" << gbuffer0_distinct << " g0_nonzero=" << nonzero0 + << "; g1_changed=" << (readback.mGBuffer1 != fixture.mGBuffer1SentinelRGBA8) + << " g1_distinct=" << gbuffer1_distinct << " g1_nonzero=" << nonzero1 + << "; g2_changed=" << (readback.mGBuffer2 != fixture.mGBuffer2SentinelRGBA16) + << " g2_distinct=" << gbuffer2_distinct << " g2_nonzero=" << nonzero2 + << "; depth_changed=" << depth_changed + << " depth_passes=" << mDepthGate.mPasses + << " depth_failures=" << mDepthGate.mFailures + << " mirror_clipped_passes=" << mDepthGate.mMirrorClippedPasses; + throw Failure(detail.str()); + } + MaterialArtifact artifact = artifactFrom(readback); + std::string artifact_error; + if (!LLRenderContract::validateMaterialArtifact(artifact, &artifact_error)) + { + throw Failure("material artifact is invalid: " + artifact_error); + } + return artifact; +} + +void VulkanMaterialRun::shutdown() noexcept +{ + if (mDevice != VK_NULL_HANDLE) + { + vkDeviceWaitIdle(mDevice); + if (mPipeline != VK_NULL_HANDLE) + vkDestroyPipeline(mDevice, mPipeline, nullptr); + if (mFramebuffer != VK_NULL_HANDLE) + vkDestroyFramebuffer(mDevice, mFramebuffer, nullptr); + if (mRenderPass != VK_NULL_HANDLE) + vkDestroyRenderPass(mDevice, mRenderPass, nullptr); + for (Image& color : mColors) + destroyImage(color); + destroyImage(mDepth); + if (mDescriptorPool != VK_NULL_HANDLE) + vkDestroyDescriptorPool(mDevice, mDescriptorPool, nullptr); + if (mPipelineLayout != VK_NULL_HANDLE) + vkDestroyPipelineLayout(mDevice, mPipelineLayout, nullptr); + for (VkDescriptorSetLayout layout : mDescriptorSetLayouts) + { + if (layout != VK_NULL_HANDLE) + vkDestroyDescriptorSetLayout(mDevice, layout, nullptr); + } + if (mSampler != VK_NULL_HANDLE) + vkDestroySampler(mDevice, mSampler, nullptr); + for (Image& texture : mTextures) + destroyImage(texture); + destroyBuffer(mParameterBuffer); + destroyBuffer(mIndexBuffer); + destroyBuffer(mVertexBuffer); + destroyBuffer(mTransferBuffer); + if (mCommandPool != VK_NULL_HANDLE) + vkDestroyCommandPool(mDevice, mCommandPool, nullptr); + vkDestroyDevice(mDevice, nullptr); + } + mPipeline = VK_NULL_HANDLE; + mFramebuffer = VK_NULL_HANDLE; + mRenderPass = VK_NULL_HANDLE; + mDescriptorPool = VK_NULL_HANDLE; + mPipelineLayout = VK_NULL_HANDLE; + mDescriptorSetLayouts = {}; + mDescriptorSets = {}; + mSampler = VK_NULL_HANDLE; + mCommandPool = VK_NULL_HANDLE; + mCommandBuffer = VK_NULL_HANDLE; + mQueue = VK_NULL_HANDLE; + mDevice = VK_NULL_HANDLE; + mPhysicalDevice = VK_NULL_HANDLE; + + if (mDebugMessenger != VK_NULL_HANDLE && mInstance != VK_NULL_HANDLE && mGlobalDispatch) + { + const auto destroy_debug = reinterpret_cast( + mGlobalDispatch->getInstanceProcAddr()(mInstance, "vkDestroyDebugUtilsMessengerEXT")); + if (destroy_debug) + destroy_debug(mInstance, mDebugMessenger, nullptr); + } + if (mInstance != VK_NULL_HANDLE) + vkDestroyInstance(mInstance, nullptr); + mDebugMessenger = VK_NULL_HANDLE; + mInstance = VK_NULL_HANDLE; + mGlobalDispatch.reset(); +} + +int fail(const std::string& reason, const std::string& detail) +{ + std::cerr << "VULKAN_MATERIAL result=fail reason=" << reason; + if (!detail.empty()) + { + std::cerr << " detail={" << detail << '}'; + } + std::cerr << '\n'; + return 1; +} + +} // namespace + +int main(int argc, char** argv) +{ + Options options; + std::string option_error; + if (!parseOptions(argc, argv, options, option_error)) + { + fail("usage", option_error); + std::cerr << "usage: llvulkanmaterial --shader-dir --output \n"; + return 2; + } + + try + { + std::error_code file_error; + if (std::filesystem::exists(options.mOutput, file_error) || file_error) + { + throw Failure(file_error ? "cannot inspect output path: " + file_error.message() + : "output artifact already exists: " + options.mOutput.string()); + } + + VulkanMaterialRun runner(options.mShaderDirectory); + MaterialArtifact artifact = runner.run(); + runner.shutdown(); + if (runner.validationMessageCount() != 0) + { + std::ostringstream message; + message << runner.validationMessageCount() << " validation messages"; + const std::string first = runner.firstValidationMessage(); + if (!first.empty()) + { + message << "; first: " << first; + } + throw Failure(message.str()); + } + + std::string artifact_error; + if (!LLRenderContract::writeMaterialArtifact(options.mOutput, artifact, &artifact_error)) + { + throw Failure(artifact_error); + } + + const DepthGate& depth = runner.depthGate(); + std::cout << "VULKAN_MATERIAL result=pass" + << " case=nonrigged_normspec_indexed" + << " components=" + << (LLRenderContract::MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT * 3 + + LLRenderContract::MATERIAL_DIAGNOSTIC_DEPTH_COMPONENT_COUNT) + << " rejection_cases=" << runner.rejectionCount() + << " recordings=" << runner.executorRecordingCount() + << " submissions=" << runner.executorSubmissionCount() + << " depth_passes=" << depth.mPasses + << " depth_failures=" << depth.mFailures + << " mirror_clipped_passes=" << depth.mMirrorClippedPasses + << " validation_messages=" << runner.validationMessageCount() + << " vendor_id=" << runner.vendorId() + << " device_id=" << runner.deviceId() + << " api_version=" << runner.apiVersion() + << " driver_version=" << runner.driverVersion() + << " portability_enumeration=" + << (runner.usedPortabilityEnumeration() ? "enabled" : "not_advertised") + << " portability_subset=" + << (runner.usedPortabilitySubset() ? "enabled" : "not_advertised") + << " artifact=written\n"; + return 0; + } + catch (const CapabilityFailure& exception) + { + return fail("capability", exception.what()); + } + catch (const std::exception& exception) + { + return fail("execution", exception.what()); + } +} diff --git a/indra/llrender/vulkan/llvulkanmaterialartifact.cpp b/indra/llrender/vulkan/llvulkanmaterialartifact.cpp new file mode 100644 index 00000000000..3a57154253e --- /dev/null +++ b/indra/llrender/vulkan/llvulkanmaterialartifact.cpp @@ -0,0 +1,401 @@ +/** + * @file llvulkanmaterialartifact.cpp + * @brief Owned loading contract for packaged Vulkan material shaders. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llvulkanmaterialartifact.h" + +#include +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + + constexpr std::uint32_t SPIRV_MAGIC = 0x07230203; + constexpr std::uint16_t SPIRV_OP_ENTRY_POINT = 15; + constexpr std::uint32_t SPIRV_EXECUTION_MODEL_VERTEX = 0; + constexpr std::uint32_t SPIRV_EXECUTION_MODEL_FRAGMENT = 4; + constexpr std::uintmax_t MIN_MODULE_BYTES = 5 * sizeof(std::uint32_t); + constexpr std::uintmax_t MAX_MODULE_BYTES = 16 * 1024 * 1024; + + constexpr std::array MODULE_DIRECTORIES{ "shaders", "vulkan", "legacy_normspec" }; + constexpr const char* VERTEX_MODULE_NAME = "production.vert.spv"; + constexpr const char* FRAGMENT_MODULE_NAME = "production.frag.spv"; + + struct ModuleExpectation + { + ShaderStage mStage = ShaderStage::Vertex; + std::string_view mEntryPoint; + std::uint32_t mExecutionModel = SPIRV_EXECUTION_MODEL_VERTEX; + const char* mFileName = VERTEX_MODULE_NAME; + }; + + using ModuleLoadResult = std::variant; + + ShaderArtifactLoadError failure(ShaderArtifactLoadCode code, std::optional stage = std::nullopt) noexcept + { + return { code, stage }; + } + + bool missing(const std::error_code& error) noexcept + { + return error == std::errc::no_such_file_or_directory || error == std::errc::not_a_directory; + } + + std::filesystem::path checkedRootPath(const std::filesystem::path& root) + { + std::filesystem::path checked = root; + while (checked != checked.root_path()) + { + const std::filesystem::path filename = checked.filename(); + if (filename.empty() || (filename == "." && !checked.parent_path().empty())) + { + checked = checked.parent_path(); + continue; + } + break; + } + return checked; + } + + std::optional executionModel(ShaderStage stage) noexcept + { + switch (stage) + { + case ShaderStage::Vertex: + return SPIRV_EXECUTION_MODEL_VERTEX; + case ShaderStage::Fragment: + return SPIRV_EXECUTION_MODEL_FRAGMENT; + } + return std::nullopt; + } + + const ShaderEntryPoint* entryPoint(const ShaderManifest& manifest, ShaderStage stage) noexcept + { + const ShaderEntryPoint* found = nullptr; + for (const ShaderEntryPoint& entry : manifest.mEntryPoints) + { + if (entry.mStage != stage) + { + continue; + } + if (found) + { + return nullptr; + } + found = &entry; + } + return found; + } + + std::optional validRoot(const std::filesystem::path& root) + { + std::error_code error; + const std::filesystem::file_status status = std::filesystem::symlink_status(root, error); + if (error) + { + return failure(missing(error) ? ShaderArtifactLoadCode::MissingRoot : ShaderArtifactLoadCode::ReadFailure); + } + if (!std::filesystem::exists(status)) + { + return failure(ShaderArtifactLoadCode::MissingRoot); + } + if (std::filesystem::is_symlink(status) || !std::filesystem::is_directory(status)) + { + return failure(ShaderArtifactLoadCode::NotRegularFile); + } + return std::nullopt; + } + + std::variant modulePath(const std::filesystem::path& root, + const ModuleExpectation& expected) + { + std::filesystem::path path = root; + for (const char* component : MODULE_DIRECTORIES) + { + path /= component; + std::error_code error; + const std::filesystem::file_status status = std::filesystem::symlink_status(path, error); + if (error) + { + return failure(missing(error) ? ShaderArtifactLoadCode::MissingModule : ShaderArtifactLoadCode::ReadFailure, + expected.mStage); + } + if (!std::filesystem::exists(status)) + { + return failure(ShaderArtifactLoadCode::MissingModule, expected.mStage); + } + if (std::filesystem::is_symlink(status) || !std::filesystem::is_directory(status)) + { + return failure(ShaderArtifactLoadCode::NotRegularFile, expected.mStage); + } + } + + path /= expected.mFileName; + std::error_code error; + const std::filesystem::file_status status = std::filesystem::symlink_status(path, error); + if (error) + { + return failure(missing(error) ? ShaderArtifactLoadCode::MissingModule : ShaderArtifactLoadCode::ReadFailure, expected.mStage); + } + if (!std::filesystem::exists(status)) + { + return failure(ShaderArtifactLoadCode::MissingModule, expected.mStage); + } + if (std::filesystem::is_symlink(status) || !std::filesystem::is_regular_file(status)) + { + return failure(ShaderArtifactLoadCode::NotRegularFile, expected.mStage); + } + return path; + } + + std::variant, ShaderArtifactLoadError> readModuleWords(const std::filesystem::path& path, ShaderStage stage) + { + std::error_code size_error; + const std::uintmax_t byte_count = std::filesystem::file_size(path, size_error); + if (size_error) + { + return failure(ShaderArtifactLoadCode::ReadFailure, stage); + } + if (byte_count < MIN_MODULE_BYTES || byte_count > MAX_MODULE_BYTES || byte_count % sizeof(std::uint32_t) != 0) + { + return failure(ShaderArtifactLoadCode::InvalidSize, stage); + } + + std::ifstream stream(path, std::ios::binary); + if (!stream.is_open()) + { + return failure(ShaderArtifactLoadCode::ReadFailure, stage); + } + + std::vector bytes(static_cast(byte_count)); + const auto stream_size = static_cast(byte_count); + stream.read(reinterpret_cast(bytes.data()), stream_size); + if (stream.gcount() != stream_size || stream.bad()) + { + return failure(ShaderArtifactLoadCode::ReadFailure, stage); + } + if (stream.peek() != std::char_traits::eof() || stream.bad()) + { + return failure(ShaderArtifactLoadCode::ReadFailure, stage); + } + + std::vector words(bytes.size() / sizeof(std::uint32_t)); + for (std::size_t index = 0; index < words.size(); ++index) + { + const std::size_t offset = index * sizeof(std::uint32_t); + words[index] = static_cast(bytes[offset]) | (static_cast(bytes[offset + 1]) << 8) | + (static_cast(bytes[offset + 2]) << 16) | (static_cast(bytes[offset + 3]) << 24); + } + return words; + } + + bool entryPointNameMatches(const std::vector& words, std::size_t first_word, std::size_t end_word, + std::string_view expected) noexcept + { + std::size_t character = 0; + for (std::size_t word_index = first_word; word_index < end_word; ++word_index) + { + const std::uint32_t word = words[word_index]; + for (std::uint32_t byte_index = 0; byte_index < sizeof(std::uint32_t); ++byte_index) + { + const auto byte = static_cast((word >> (byte_index * 8)) & 0xff); + if (byte == 0) + { + for (++byte_index; byte_index < sizeof(std::uint32_t); ++byte_index) + { + if (((word >> (byte_index * 8)) & 0xff) != 0) + { + return false; + } + } + return character == expected.size(); + } + if (character >= expected.size() || byte != static_cast(expected[character])) + { + return false; + } + ++character; + } + } + return false; + } + + std::optional validateModule(const std::vector& words, + const ModuleExpectation& expected) noexcept + { + if (words.size() < 5 || words.size() > MAX_MODULE_BYTES / sizeof(std::uint32_t) || words[0] != SPIRV_MAGIC) + { + return ShaderArtifactLoadCode::InvalidSpirv; + } + + std::size_t entry_point_count = 0; + bool entry_point_well_formed = false; + std::uint32_t entry_execution_model = 0; + for (std::size_t cursor = 5; cursor < words.size();) + { + const std::uint32_t instruction = words[cursor]; + const std::uint32_t word_count = instruction >> 16; + const std::uint16_t opcode = static_cast(instruction & 0xffff); + if (word_count == 0 || word_count > words.size() - cursor) + { + return ShaderArtifactLoadCode::InvalidSpirv; + } + + if (opcode == SPIRV_OP_ENTRY_POINT) + { + ++entry_point_count; + if (word_count >= 4) + { + entry_execution_model = words[cursor + 1]; + entry_point_well_formed = entryPointNameMatches(words, cursor + 3, cursor + word_count, expected.mEntryPoint); + } + } + cursor += word_count; + } + + if (entry_point_count != 1 || !entry_point_well_formed) + { + return ShaderArtifactLoadCode::WrongEntryPoint; + } + if (entry_execution_model != expected.mExecutionModel) + { + return ShaderArtifactLoadCode::WrongStage; + } + return std::nullopt; + } + + ModuleLoadResult loadModule(const std::filesystem::path& root, const ModuleExpectation& expected) + { + auto path = modulePath(root, expected); + if (const auto* error = std::get_if(&path)) + { + return *error; + } + + auto words = readModuleWords(std::get(path), expected.mStage); + if (const auto* error = std::get_if(&words)) + { + return *error; + } + + std::vector owned_words = std::move(std::get>(words)); + if (const auto validation_error = validateModule(owned_words, expected)) + { + return failure(*validation_error, expected.mStage); + } + return LoadedShaderModule{ expected.mStage, std::string(expected.mEntryPoint), std::move(owned_words) }; + } + +} // namespace + +bool operator==(const LoadedShaderProgram& left, const LoadedShaderProgram& right) +{ + return left.mProgram.mName == right.mProgram.mName && left.mProgram.mVariant == right.mProgram.mVariant && + left.mVertex == right.mVertex && left.mFragment == right.mFragment; +} + +bool validLegacyNormSpecProductionShaderProgram(const LoadedShaderProgram& program) noexcept +{ + try + { + const auto manifest = legacyNormSpecShaderManifest(legacyNormSpecModernHDRPipelineKey(), ShaderBackend::Vulkan); + if (!manifest || !validLegacyNormSpecProductionShaderManifest(*manifest) || program.mProgram.mName != manifest->mProgram.mName || + program.mProgram.mVariant != manifest->mProgram.mVariant) + { + return false; + } + + const ShaderEntryPoint* vertex_entry = entryPoint(*manifest, ShaderStage::Vertex); + const ShaderEntryPoint* fragment_entry = entryPoint(*manifest, ShaderStage::Fragment); + const auto vertex_model = executionModel(ShaderStage::Vertex); + const auto fragment_model = executionModel(ShaderStage::Fragment); + if (!vertex_entry || !fragment_entry || !vertex_model || !fragment_model) + { + return false; + } + + const ModuleExpectation vertex_expected{ ShaderStage::Vertex, vertex_entry->mName, *vertex_model, VERTEX_MODULE_NAME }; + const ModuleExpectation fragment_expected{ ShaderStage::Fragment, fragment_entry->mName, *fragment_model, FRAGMENT_MODULE_NAME }; + return program.mVertex.mStage == vertex_expected.mStage && program.mVertex.mEntryPoint == vertex_expected.mEntryPoint && + !validateModule(program.mVertex.mWords, vertex_expected) && program.mFragment.mStage == fragment_expected.mStage && + program.mFragment.mEntryPoint == fragment_expected.mEntryPoint && + !validateModule(program.mFragment.mWords, fragment_expected); + } + catch (...) + { + return false; + } +} + +ShaderArtifactLoadResult loadLegacyNormSpecProductionArtifacts(const std::filesystem::path& app_settings_root) noexcept +{ + try + { + const auto manifest = legacyNormSpecShaderManifest(legacyNormSpecModernHDRPipelineKey(), ShaderBackend::Vulkan); + if (!manifest || !validLegacyNormSpecProductionShaderManifest(*manifest)) + { + return failure(ShaderArtifactLoadCode::InvalidManifest); + } + + const ShaderEntryPoint* vertex_entry = entryPoint(*manifest, ShaderStage::Vertex); + const ShaderEntryPoint* fragment_entry = entryPoint(*manifest, ShaderStage::Fragment); + const auto vertex_model = executionModel(ShaderStage::Vertex); + const auto fragment_model = executionModel(ShaderStage::Fragment); + if (!vertex_entry || !fragment_entry || !vertex_model || !fragment_model) + { + return failure(ShaderArtifactLoadCode::InvalidManifest); + } + + const std::filesystem::path checked_root = checkedRootPath(app_settings_root); + if (const auto root_error = validRoot(checked_root)) + { + return *root_error; + } + + const ModuleExpectation vertex_expected{ ShaderStage::Vertex, vertex_entry->mName, *vertex_model, VERTEX_MODULE_NAME }; + const ModuleExpectation fragment_expected{ ShaderStage::Fragment, fragment_entry->mName, *fragment_model, FRAGMENT_MODULE_NAME }; + + auto vertex = loadModule(checked_root, vertex_expected); + if (const auto* error = std::get_if(&vertex)) + { + return *error; + } + auto fragment = loadModule(checked_root, fragment_expected); + if (const auto* error = std::get_if(&fragment)) + { + return *error; + } + + LoadedShaderProgram loaded{ manifest->mProgram, std::move(std::get(vertex)), + std::move(std::get(fragment)) }; + if (!validLegacyNormSpecProductionShaderProgram(loaded)) + { + return failure(ShaderArtifactLoadCode::InvalidManifest); + } + return loaded; + } + catch (...) + { + return failure(ShaderArtifactLoadCode::ReadFailure); + } +} + +} // namespace LLRenderContract diff --git a/indra/llrender/vulkan/llvulkanmaterialartifact.h b/indra/llrender/vulkan/llvulkanmaterialartifact.h new file mode 100644 index 00000000000..0d01c73c57a --- /dev/null +++ b/indra/llrender/vulkan/llvulkanmaterialartifact.h @@ -0,0 +1,81 @@ +/** + * @file llvulkanmaterialartifact.h + * @brief Owned loading contract for packaged Vulkan material shaders. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLVULKANMATERIALARTIFACT_H +#define LL_LLVULKANMATERIALARTIFACT_H + +#include "llshadermanifest.h" + +#include +#include +#include +#include +#include +#include + +namespace LLRenderContract +{ + +enum class ShaderArtifactLoadCode : std::uint8_t +{ + InvalidManifest, + MissingRoot, + MissingModule, + NotRegularFile, + InvalidSize, + ReadFailure, + InvalidSpirv, + WrongStage, + WrongEntryPoint +}; + +struct ShaderArtifactLoadError +{ + ShaderArtifactLoadCode mCode = ShaderArtifactLoadCode::InvalidManifest; + std::optional mStage; + + friend constexpr bool operator==(const ShaderArtifactLoadError&, const ShaderArtifactLoadError&) = default; +}; + +struct LoadedShaderModule +{ + ShaderStage mStage = ShaderStage::Vertex; + std::string mEntryPoint; + std::vector mWords; + + friend bool operator==(const LoadedShaderModule&, const LoadedShaderModule&) = default; +}; + +struct LoadedShaderProgram +{ + ShaderProgramKey mProgram; + LoadedShaderModule mVertex; + LoadedShaderModule mFragment{ ShaderStage::Fragment, {}, {} }; + + friend bool operator==(const LoadedShaderProgram& left, const LoadedShaderProgram& right); +}; + +// The app-settings tree is trusted read-only installation data. Callers must +// not modify it concurrently with a load; the portable checks below detect +// corruption, but are not a hostile-filesystem sandbox. +using ShaderArtifactLoadResult = std::variant; + +bool validLegacyNormSpecProductionShaderProgram(const LoadedShaderProgram& program) noexcept; + +ShaderArtifactLoadResult loadLegacyNormSpecProductionArtifacts(const std::filesystem::path& app_settings_root) noexcept; + +} // namespace LLRenderContract + +#endif // LL_LLVULKANMATERIALARTIFACT_H diff --git a/indra/llrender/vulkan/llvulkanmaterialpublication.cpp b/indra/llrender/vulkan/llvulkanmaterialpublication.cpp new file mode 100644 index 00000000000..897a4cceaba --- /dev/null +++ b/indra/llrender/vulkan/llvulkanmaterialpublication.cpp @@ -0,0 +1,127 @@ +/** + * @file llvulkanmaterialpublication.cpp + * @brief API-neutral publication lifecycle for owned material shader bytes. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llvulkanmaterialpublication.h" + +#include +#include +#include + +namespace LLRenderContract +{ +namespace +{ + + constexpr std::uint32_t LEGACY_NORMSPEC_SHADER_INDEX = 1; + + bool sameProgram(const ShaderProgramKey& left, const ShaderProgramKey& right) noexcept + { + return left.mName == right.mName && left.mVariant == right.mVariant; + } + +} // namespace + +std::optional LegacyNormSpecShaderPublication::publish(const LoadedShaderProgram& program) noexcept +{ + if (!validLegacyNormSpecProductionShaderProgram(program)) + { + return std::nullopt; + } + + try + { + auto immutable = std::make_shared(program); + if (!mCurrent) + { + mCurrent.emplace(Generation{ { LEGACY_NORMSPEC_SHADER_INDEX, 1 }, std::move(immutable), 0 }); + return mCurrent->mHandle; + } + + const auto next_handle = nextHandleGeneration(mCurrent->mHandle); + if (!next_handle) + { + return std::nullopt; + } + + Generation replacement{ *next_handle, std::move(immutable), 0 }; + mPending.push_back(*mCurrent); + static_assert(std::is_nothrow_move_assignable_v); + *mCurrent = std::move(replacement); + return mCurrent->mHandle; + } + catch (...) + { + return std::nullopt; + } +} + +std::optional LegacyNormSpecShaderPublication::current(const ShaderProgramKey& program) const noexcept +{ + if (!mCurrent || !sameProgram(mCurrent->mProgram->mProgram, program)) + { + return std::nullopt; + } + return mCurrent->mHandle; +} + +std::optional LegacyNormSpecShaderPublication::resolveForFrame(ShaderHandle handle, std::uint64_t frame) noexcept +{ + if (!mCurrent || handle != mCurrent->mHandle || frame == 0 || frame <= mCompletedThrough || frame < mLastRecordedFrame) + { + return std::nullopt; + } + + mCurrent->mLastFrame = std::max(mCurrent->mLastFrame, frame); + mLastRecordedFrame = frame; + return ShaderGenerationLease{ handle, frame, mCurrent->mProgram }; +} + +std::optional> LegacyNormSpecShaderPublication::completeThrough( + std::uint64_t completed_frame) noexcept +{ + if (completed_frame == 0 || completed_frame < mCompletedThrough) + { + return std::nullopt; + } + + try + { + std::vector retired; + std::vector remaining; + retired.reserve(mPending.size()); + remaining.reserve(mPending.size()); + for (const Generation& generation : mPending) + { + if (generation.mLastFrame <= completed_frame) + { + retired.push_back({ generation.mHandle, generation.mLastFrame }); + } + else + { + remaining.push_back(generation); + } + } + + mPending = std::move(remaining); + mCompletedThrough = completed_frame; + return retired; + } + catch (...) + { + return std::nullopt; + } +} + +} // namespace LLRenderContract diff --git a/indra/llrender/vulkan/llvulkanmaterialpublication.h b/indra/llrender/vulkan/llvulkanmaterialpublication.h new file mode 100644 index 00000000000..5d34e78ccb1 --- /dev/null +++ b/indra/llrender/vulkan/llvulkanmaterialpublication.h @@ -0,0 +1,85 @@ +/** + * @file llvulkanmaterialpublication.h + * @brief API-neutral publication lifecycle for owned material shader bytes. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLVULKANMATERIALPUBLICATION_H +#define LL_LLVULKANMATERIALPUBLICATION_H + +#include "llvulkanmaterialartifact.h" + +#include +#include +#include +#include + +namespace LLRenderContract +{ + +struct ShaderGenerationLease +{ + ShaderHandle mHandle; + std::uint64_t mFrame = 0; + std::shared_ptr mProgram; +}; + +struct ShaderGenerationRetirement +{ + ShaderHandle mHandle; + std::uint64_t mLastFrame = 0; + + friend constexpr bool operator==(const ShaderGenerationRetirement&, const ShaderGenerationRetirement&) = default; +}; + +// One externally sequenced owner for the canonical legacy norm-spec program. +// Every recorded frame must acquire its own lease. Reusing a lease for a later +// frame would bypass last-use tracking and is outside this contract. Handles +// are relative to one owner instance and must never cross between owners. Old +// and replacement leases may coexist within one frame; both remain owned until +// that frame completes. +class LegacyNormSpecShaderPublication +{ +public: + LegacyNormSpecShaderPublication() = default; + ~LegacyNormSpecShaderPublication() = default; + + LegacyNormSpecShaderPublication(const LegacyNormSpecShaderPublication&) = delete; + LegacyNormSpecShaderPublication& operator=(const LegacyNormSpecShaderPublication&) = delete; + LegacyNormSpecShaderPublication(LegacyNormSpecShaderPublication&&) = delete; + LegacyNormSpecShaderPublication& operator=(LegacyNormSpecShaderPublication&&) = delete; + + std::optional publish(const LoadedShaderProgram& program) noexcept; + std::optional current(const ShaderProgramKey& program) const noexcept; + + std::optional resolveForFrame(ShaderHandle handle, std::uint64_t frame) noexcept; + std::optional> completeThrough(std::uint64_t completed_frame) noexcept; + + std::uint64_t completedThrough() const noexcept { return mCompletedThrough; } + +private: + struct Generation + { + ShaderHandle mHandle; + std::shared_ptr mProgram; + std::uint64_t mLastFrame = 0; + }; + + std::optional mCurrent; + std::vector mPending; + std::uint64_t mCompletedThrough = 0; + std::uint64_t mLastRecordedFrame = 0; +}; + +} // namespace LLRenderContract + +#endif // LL_LLVULKANMATERIALPUBLICATION_H diff --git a/indra/llrender/vulkan/llvulkantextureupload_main.cpp b/indra/llrender/vulkan/llvulkantextureupload_main.cpp new file mode 100644 index 00000000000..a9425951488 --- /dev/null +++ b/indra/llrender/vulkan/llvulkantextureupload_main.cpp @@ -0,0 +1,2252 @@ +/** + * @file llvulkantextureupload_main.cpp + * @brief Offscreen Vulkan replay for the fixed streamed-texture upload. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkantextureupload.h" +#include "lltextureuploaddiagnostic.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +using LLRenderContract::Extent2D; +using LLRenderContract::FrameSnapshot; +using LLRenderContract::StreamingUploadLifecycle; +using LLRenderContract::TextureUploadArtifact; +using LLRenderContract::TextureUploadCase; +using LLRenderContract::TextureUploadFixture; +using LLRenderVulkanTextureUpload::ShaderIdentityToken; + +constexpr char PORTABILITY_ENUMERATION_EXTENSION[] = "VK_KHR_portability_enumeration"; +constexpr char PORTABILITY_SUBSET_EXTENSION[] = "VK_KHR_portability_subset"; +constexpr VkFormat COLOR_FORMAT = VK_FORMAT_R8G8B8A8_UNORM; +constexpr Extent2D RESIDENT_EXTENT{ LLRenderContract::TEXTURE_UPLOAD_RESIDENT_WIDTH, + LLRenderContract::TEXTURE_UPLOAD_RESIDENT_HEIGHT }; +constexpr Extent2D OUTPUT_EXTENT{ LLRenderContract::TEXTURE_UPLOAD_OUTPUT_WIDTH, + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_HEIGHT }; +constexpr VkDeviceSize SCREEN_BYTES = 48; +constexpr VkDeviceSize STAGING_BYTES = LLRenderContract::TEXTURE_UPLOAD_SOURCE_BYTE_COUNT; +constexpr VkDeviceSize READBACK_BYTES = LLRenderContract::TEXTURE_UPLOAD_MIP_BYTE_COUNT + + LLRenderContract::TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT; +constexpr VkImageUsageFlags REPLACEMENT_USAGE = VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT; +constexpr VkImageUsageFlags OUTPUT_USAGE = VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT; + +static_assert(SCREEN_BYTES == 48); +static_assert(STAGING_BYTES == 144); +static_assert(READBACK_BYTES == 200); + +class Failure : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +class CapabilityFailure : public Failure +{ +public: + using Failure::Failure; +}; + +LLRenderVulkanTextureUpload::NativeOwnershipToken nextOwnershipToken() +{ + static std::atomic next{ 1 }; + auto candidate = next.load(std::memory_order_relaxed); + for (;;) + { + if (candidate == 0 || candidate == std::numeric_limits::max()) + { + throw Failure("Vulkan diagnostic ownership tokens are exhausted"); + } + if (next.compare_exchange_weak(candidate, candidate + 1, + std::memory_order_relaxed, + std::memory_order_relaxed)) + { + return candidate; + } + } +} + +void check(VkResult result, const char* operation) +{ + if (result != VK_SUCCESS) + { + std::ostringstream message; + message << operation << " failed with VkResult " << result; + throw Failure(message.str()); + } +} + +template +std::vector enumerate(Function&& function, const char* operation) +{ + for (;;) + { + std::uint32_t count = 0; + check(function(&count, nullptr), operation); + std::vector values(count); + const VkResult result = function(&count, values.data()); + if (result == VK_INCOMPLETE) + { + continue; + } + check(result, operation); + values.resize(count); + return values; + } +} + +template +bool hasName(const std::vector& properties, const char* expected) +{ + return std::any_of(properties.begin(), properties.end(), [expected](const Property& property) + { + if constexpr (std::is_same_v) + { + return std::strcmp(property.layerName, expected) == 0; + } + else + { + return std::strcmp(property.extensionName, expected) == 0; + } + }); +} + +struct Options +{ + std::filesystem::path mShaderDirectory; + std::filesystem::path mOutput; +}; + +bool parseOptions(int argc, char** argv, Options& options, std::string& error) +{ + bool have_shader_directory = false; + bool have_output = false; + for (int index = 1; index < argc; ++index) + { + const std::string argument = argv[index]; + if (argument != "--shader-dir" && argument != "--output") + { + error = "unknown argument: " + argument; + return false; + } + if (index + 1 >= argc) + { + error = argument + " requires a path"; + return false; + } + const std::filesystem::path value = argv[++index]; + if (value.empty()) + { + error = argument + " requires a non-empty path"; + return false; + } + if (argument == "--shader-dir") + { + if (have_shader_directory) + { + error = "--shader-dir was specified more than once"; + return false; + } + options.mShaderDirectory = value; + have_shader_directory = true; + } + else + { + if (have_output) + { + error = "--output was specified more than once"; + return false; + } + options.mOutput = value; + have_output = true; + } + } + if (!have_shader_directory || !have_output) + { + error = "both --shader-dir and --output are required"; + return false; + } + return true; +} + +bool occupiedPath(const std::filesystem::path& path, std::string& error) +{ + std::error_code status_error; + const std::filesystem::file_status status = std::filesystem::symlink_status(path, status_error); + if (!status_error) + { + return status.type() != std::filesystem::file_type::not_found; + } + if (status_error == std::errc::no_such_file_or_directory) + { + return false; + } + error = "cannot inspect output path: " + status_error.message(); + return true; +} + +struct ValidationState +{ + std::atomic mMessages{ 0 }; + std::mutex mMutex; + std::string mFirstMessage; +}; + +VKAPI_ATTR VkBool32 VKAPI_CALL validationCallback( + VkDebugUtilsMessageSeverityFlagBitsEXT, + VkDebugUtilsMessageTypeFlagsEXT, + const VkDebugUtilsMessengerCallbackDataEXT* callback_data, + void* user_data) +{ + auto& state = *static_cast(user_data); + ++state.mMessages; + std::lock_guard lock(state.mMutex); + if (state.mFirstMessage.empty() && callback_data && callback_data->pMessage) + { + state.mFirstMessage = callback_data->pMessage; + } + return VK_FALSE; +} + +std::uint32_t rotateRight(std::uint32_t value, std::uint32_t shift) +{ + return (value >> shift) | (value << (32U - shift)); +} + +ShaderIdentityToken sha256(const std::vector& input) +{ + constexpr std::array K{ + 0x428a2f98U, 0x71374491U, 0xb5c0fbcfU, 0xe9b5dba5U, 0x3956c25bU, 0x59f111f1U, 0x923f82a4U, 0xab1c5ed5U, + 0xd807aa98U, 0x12835b01U, 0x243185beU, 0x550c7dc3U, 0x72be5d74U, 0x80deb1feU, 0x9bdc06a7U, 0xc19bf174U, + 0xe49b69c1U, 0xefbe4786U, 0x0fc19dc6U, 0x240ca1ccU, 0x2de92c6U, 0x4a7484aaU, 0x5cb0a9dcU, 0x76f988daU, + 0x983e5152U, 0xa831c66dU, 0xb00327c8U, 0xbf597fc7U, 0xc6e00bf3U, 0xd5a79147U, 0x06ca6351U, 0x14292967U, + 0x27b70a85U, 0x2e1b2138U, 0x4d2c6dfcU, 0x53380d13U, 0x650a7354U, 0x766a0abbU, 0x81c2c92eU, 0x92722c85U, + 0xa2bfe8a1U, 0xa81a664bU, 0xc24b8b70U, 0xc76c51a3U, 0xd192e819U, 0xd6990624U, 0xf40e3585U, 0x106aa070U, + 0x19a4c116U, 0x1e376c08U, 0x2748774cU, 0x34b0bcb5U, 0x391c0cb3U, 0x4ed8aa4aU, 0x5b9cca4fU, 0x682e6ff3U, + 0x748f82eeU, 0x78a5636fU, 0x84c87814U, 0x8cc70208U, 0x90befffaU, 0xa4506cebU, 0xbef9a3f7U, 0xc67178f2U + }; + std::array state{ 0x6a09e667U, 0xbb67ae85U, 0x3c6ef372U, 0xa54ff53aU, + 0x510e527fU, 0x9b05688cU, 0x1f83d9abU, 0x5be0cd19U }; + std::vector padded = input; + const std::uint64_t bit_size = static_cast(input.size()) * 8U; + padded.push_back(0x80U); + while (padded.size() % 64 != 56) + { + padded.push_back(0); + } + for (int shift = 56; shift >= 0; shift -= 8) + { + padded.push_back(static_cast(bit_size >> shift)); + } + for (std::size_t offset = 0; offset < padded.size(); offset += 64) + { + std::array words{}; + for (std::size_t index = 0; index < 16; ++index) + { + const std::size_t byte = offset + index * 4; + words[index] = static_cast(padded[byte]) << 24 | + static_cast(padded[byte + 1]) << 16 | + static_cast(padded[byte + 2]) << 8 | + static_cast(padded[byte + 3]); + } + for (std::size_t index = 16; index < words.size(); ++index) + { + const std::uint32_t s0 = rotateRight(words[index - 15], 7) ^ rotateRight(words[index - 15], 18) ^ + (words[index - 15] >> 3); + const std::uint32_t s1 = rotateRight(words[index - 2], 17) ^ rotateRight(words[index - 2], 19) ^ + (words[index - 2] >> 10); + words[index] = words[index - 16] + s0 + words[index - 7] + s1; + } + std::uint32_t a = state[0], b = state[1], c = state[2], d = state[3]; + std::uint32_t e = state[4], f = state[5], g = state[6], h = state[7]; + for (std::size_t index = 0; index < words.size(); ++index) + { + const std::uint32_t s1 = rotateRight(e, 6) ^ rotateRight(e, 11) ^ rotateRight(e, 25); + const std::uint32_t choose = (e & f) ^ (~e & g); + const std::uint32_t temporary1 = h + s1 + choose + K[index] + words[index]; + const std::uint32_t s0 = rotateRight(a, 2) ^ rotateRight(a, 13) ^ rotateRight(a, 22); + const std::uint32_t majority = (a & b) ^ (a & c) ^ (b & c); + const std::uint32_t temporary2 = s0 + majority; + h = g; g = f; f = e; e = d + temporary1; + d = c; c = b; b = a; a = temporary1 + temporary2; + } + state[0] += a; state[1] += b; state[2] += c; state[3] += d; + state[4] += e; state[5] += f; state[6] += g; state[7] += h; + } + ShaderIdentityToken result{}; + for (std::size_t index = 0; index < state.size(); ++index) + { + result[index * 4] = static_cast(state[index] >> 24); + result[index * 4 + 1] = static_cast(state[index] >> 16); + result[index * 4 + 2] = static_cast(state[index] >> 8); + result[index * 4 + 3] = static_cast(state[index]); + } + return result; +} + +struct Buffer +{ + VkBuffer mBuffer = VK_NULL_HANDLE; + VkDeviceMemory mMemory = VK_NULL_HANDLE; + void* mMapped = nullptr; + VkDeviceSize mSize = 0; + VkDeviceSize mAllocationSize = 0; + VkBufferUsageFlags mUsage = 0; + VkMemoryPropertyFlags mMemoryProperties = 0; +}; + +struct Image +{ + VkImage mImage = VK_NULL_HANDLE; + VkDeviceMemory mMemory = VK_NULL_HANDLE; + VkImageView mView = VK_NULL_HANDLE; + VkFormat mFormat = VK_FORMAT_UNDEFINED; + Extent2D mExtent; + std::uint32_t mMipLevels = 0; + VkImageUsageFlags mUsage = 0; + VkDeviceSize mAllocationSize = 0; + VkImageLayout mLayout = VK_IMAGE_LAYOUT_UNDEFINED; + VkImageSubresourceRange mViewRange{}; +}; + +enum class RegistryMutation +{ + None, + StaleScreen, + StaleOld, + StaleReplacement, + StaleOutput, + StaleSampler, + StalePipeline, + WrongScreenSize, + WrongScreenUsage, + WrongStagingSize, + WrongStagingUsage, + WrongStagingMemory, + WrongReadbackSize, + WrongReadbackUsage, + WrongReadbackMemory, + AliasBuffers, + AliasBufferMemory, + WrongOldFormat, + WrongOldExtent, + WrongOldMips, + WrongOldUsage, + WrongOldLayout, + WrongOldViewRange, + WrongOldSnapshot, + WrongReplacementFormat, + WrongReplacementExtent, + WrongReplacementMips, + WrongReplacementUsage, + WrongReplacementLayout, + WrongReplacementViewRange, + WrongOutputFormat, + WrongOutputExtent, + WrongOutputMips, + WrongOutputUsage, + WrongOutputLayout, + WrongOutputViewRange, + AliasImages, + AliasImageMemory, + AliasViews, + WrongSampler, + WrongProgram, + WrongVariant, + WrongPipelineExtent, + WrongDescriptor, + WrongRenderPass, + WrongFramebuffer, + WrongVertexState, + WrongViewportPolicy, + WrongRasterState, + WrongShaderIdentity, + MissingLifecycle, + WrongLifecycleCurrent, + WrongLifecycleRevision, + PendingLifecycle, + PriorCompletion, + PriorRetirement, + WrongOwnershipToken +}; + +enum class PacketMutation +{ + None, + WrongFrame, + WrongUploadDestinationGeneration, + WrongOldPacketGeneration, + WrongOutputPacketGeneration, + PacketImageAlias, + WrongOldPacketMipCount, + WrongReplacementPacketMipCount, + WrongOutputPacketMipCount, + DuplicateRevision, + WrongSubresource, + WrongOffset, + WrongResidentExtent, + WrongLogicalExtent, + WrongResidentDiscard, + WrongRowPitch, + WrongRowOrigin, + WrongSourceFormat, + WrongMipGeneration, + WrongBefore, + WrongDuring, + WrongAfter, + WrongPixelOffset, + WrongPixelSize, + WrongPixelStorageSize, + WrongSamplerPacket, + MissingSamplerPacket, + WrongPipelinePacket, + MissingPipelinePacket, + WrongOutputPacketExtent, + WrongReleaseFrame, + WrongRelease, + MissingRelease, + ExtraPass, + MissingPass, + WrongViewport, + MissingDraw, + ExtraDraw, + WrongSampleRange +}; + +enum class ContextMutation +{ + None, + NullDevice, + NullCommandBuffer, + NullQueue, + MissingRecordingCounter, + MissingSubmissionCounter, + WrongVertexIdentity, + WrongFragmentIdentity, + NullCommandPool, + WrongOwnershipToken, + IgnoredQueueFamily, + MissingGraphicsQueueFlag, + MissingTransferQueueFlag, + WrongQueueCount, + WrongQueueIndex, + WrongCommandPoolFamily, + MissingCommandPoolReset, + WrongCommandBufferLevel +}; + +struct RejectionSpec +{ + const char* mName; + RegistryMutation mRegistry = RegistryMutation::None; + PacketMutation mPacket = PacketMutation::None; + ContextMutation mContext = ContextMutation::None; +}; + +class VulkanTextureUploadRun +{ +public: + explicit VulkanTextureUploadRun(std::filesystem::path shader_directory) + : mShaderDirectory(std::move(shader_directory)), + mOwnershipToken(nextOwnershipToken()) + { + } + + ~VulkanTextureUploadRun() { shutdown(); } + + TextureUploadArtifact run(); + void shutdown() noexcept; + + std::uint32_t validationMessageCount() const noexcept { return mValidation.mMessages.load(); } + std::uint64_t recordingAttemptCount() const noexcept { return mExecutorRecordings; } + std::uint64_t submissionCount() const noexcept { return mExecutorSubmissions; } + std::size_t rejectionCount() const noexcept { return mRejectionCount; } + bool usedPortabilityEnumeration() const noexcept { return mPortabilityEnumeration; } + bool usedPortabilitySubset() const noexcept { return mPortabilitySubset; } + +private: + void readShaders(); + void createInstance(); + bool hasRequiredFormats(VkPhysicalDevice physical_device) const; + bool hasRequiredMemory(VkPhysicalDevice physical_device) const; + std::optional graphicsQueueFamily(VkPhysicalDevice physical_device) const; + std::vector deviceExtensions(VkPhysicalDevice physical_device) const; + void selectPhysicalDevice(); + void createDevice(); + void createCommandResources(); + std::uint32_t memoryType(std::uint32_t bits, VkMemoryPropertyFlags required, + VkMemoryPropertyFlags preferred, VkMemoryPropertyFlags& selected) const; + Buffer createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, bool mapped); + Image createImage(Extent2D extent, std::uint32_t mip_levels, VkImageUsageFlags usage); + void destroyBuffer(Buffer& buffer) noexcept; + void destroyImage(Image& image) noexcept; + void createResources(const TextureUploadFixture& fixture); + void createDescriptors(); + void createRenderPass(); + void createPipeline(); + void seedImages(const TextureUploadFixture& fixture); + void submitDiagnostic(const std::function& recorder); + void writeBuffer(Buffer& buffer, const void* source, std::size_t size); + std::vector readBuffer(const Buffer& buffer, std::size_t size); + std::vector snapshotImage(Image& image); + void transition(Image& image, VkImageLayout old_layout, VkImageLayout new_layout, + VkAccessFlags source_access, VkAccessFlags destination_access, + VkPipelineStageFlags source_stage, VkPipelineStageFlags destination_stage); + FrameSnapshot mutateFrame(const TextureUploadCase& diagnostic_case, PacketMutation mutation) const; + LLRenderVulkanTextureUpload::Registry makeRegistry(const TextureUploadCase& diagnostic_case, + RegistryMutation mutation, + StreamingUploadLifecycle* lifecycle); + LLRenderVulkanTextureUpload::ExecutionContext executionContext(ContextMutation mutation = ContextMutation::None); + void runRejections(const TextureUploadCase& diagnostic_case, const TextureUploadFixture& fixture); + + std::filesystem::path mShaderDirectory; + LLRenderVulkanTextureUpload::NativeOwnershipToken mOwnershipToken = 0; + ValidationState mValidation; + VkInstance mInstance = VK_NULL_HANDLE; + VkDebugUtilsMessengerEXT mDebugMessenger = VK_NULL_HANDLE; + VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE; + VkPhysicalDeviceProperties mDeviceProperties{}; + std::uint32_t mQueueFamily = 0; + VkQueueFlags mQueueFamilyFlags = 0; + VkDevice mDevice = VK_NULL_HANDLE; + VkQueue mQueue = VK_NULL_HANDLE; + VkCommandPool mCommandPool = VK_NULL_HANDLE; + VkCommandBuffer mCommandBuffer = VK_NULL_HANDLE; + Buffer mScreen; + Buffer mStaging; + Buffer mReadback; + Image mOld; + Image mReplacement; + Image mOutputImage; + VkSampler mSampler = VK_NULL_HANDLE; + VkDescriptorSetLayout mDescriptorSetLayout = VK_NULL_HANDLE; + VkDescriptorPool mDescriptorPool = VK_NULL_HANDLE; + VkDescriptorSet mDescriptorSet = VK_NULL_HANDLE; + VkPipelineLayout mPipelineLayout = VK_NULL_HANDLE; + VkRenderPass mRenderPass = VK_NULL_HANDLE; + VkFramebuffer mFramebuffer = VK_NULL_HANDLE; + VkPipeline mPipeline = VK_NULL_HANDLE; + std::vector mVertexSpirv; + std::vector mFragmentSpirv; + ShaderIdentityToken mVertexIdentity{}; + ShaderIdentityToken mFragmentIdentity{}; + std::array mOldPreExecutionSnapshot{}; + std::uint64_t mExecutorRecordings = 0; + std::uint64_t mExecutorSubmissions = 0; + std::size_t mRejectionCount = 0; + bool mPortabilityEnumeration = false; + bool mPortabilitySubset = false; +}; + +void VulkanTextureUploadRun::readShaders() +{ + std::error_code directory_error; + if (!std::filesystem::is_directory(mShaderDirectory, directory_error) || directory_error) + { + throw Failure("shader directory is not readable: " + mShaderDirectory.string()); + } + const auto read = [](const std::filesystem::path& path, std::vector& words, + ShaderIdentityToken& identity) + { + std::error_code size_error; + const std::uintmax_t size = std::filesystem::file_size(path, size_error); + if (size_error || size == 0 || size % sizeof(std::uint32_t) != 0 || size > 16U * 1024U * 1024U) + { + throw Failure("SPIR-V file is missing or has an invalid size: " + path.string()); + } + std::ifstream input(path, std::ios::binary); + if (!input) + { + throw Failure("cannot open SPIR-V file: " + path.string()); + } + std::vector bytes(static_cast(size)); + if (!input.read(reinterpret_cast(bytes.data()), static_cast(bytes.size()))) + { + throw Failure("cannot read complete SPIR-V file: " + path.string()); + } + words.resize(bytes.size() / sizeof(std::uint32_t)); + std::memcpy(words.data(), bytes.data(), bytes.size()); + if (words.front() != 0x07230203U) + { + throw Failure("SPIR-V file has the wrong magic word: " + path.string()); + } + identity = sha256(bytes); + }; + read(mShaderDirectory / "textureupload.vert.spv", mVertexSpirv, mVertexIdentity); + read(mShaderDirectory / "textureupload.frag.spv", mFragmentSpirv, mFragmentIdentity); +} + +void VulkanTextureUploadRun::createInstance() +{ + const auto layers = enumerate( + [](std::uint32_t* count, VkLayerProperties* values) + { + return vkEnumerateInstanceLayerProperties(count, values); + }, + "vkEnumerateInstanceLayerProperties"); + if (!hasName(layers, "VK_LAYER_KHRONOS_validation")) + { + throw CapabilityFailure("VK_LAYER_KHRONOS_validation is required but unavailable"); + } + const auto extensions = enumerate( + [](std::uint32_t* count, VkExtensionProperties* values) + { + return vkEnumerateInstanceExtensionProperties(nullptr, count, values); + }, + "vkEnumerateInstanceExtensionProperties"); + if (!hasName(extensions, VK_EXT_DEBUG_UTILS_EXTENSION_NAME)) + { + throw CapabilityFailure("VK_EXT_debug_utils is required but unavailable"); + } + std::uint32_t loader_version = VK_API_VERSION_1_0; + const auto enumerate_version = reinterpret_cast( + vkGetInstanceProcAddr(VK_NULL_HANDLE, "vkEnumerateInstanceVersion")); + if (enumerate_version) + { + check(enumerate_version(&loader_version), "vkEnumerateInstanceVersion"); + } + if (loader_version < VK_API_VERSION_1_1) + { + throw CapabilityFailure("the Vulkan 1.1 loader required by the shader target is unavailable"); + } + + std::vector enabled_extensions{ VK_EXT_DEBUG_UTILS_EXTENSION_NAME }; + VkInstanceCreateFlags flags = 0; + if (hasName(extensions, PORTABILITY_ENUMERATION_EXTENSION)) + { + enabled_extensions.push_back(PORTABILITY_ENUMERATION_EXTENSION); + flags |= VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR; + mPortabilityEnumeration = true; + } + + VkDebugUtilsMessengerCreateInfoEXT debug_info{ VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT }; + debug_info.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT; + debug_info.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT; + debug_info.pfnUserCallback = validationCallback; + debug_info.pUserData = &mValidation; + + VkApplicationInfo application{ VK_STRUCTURE_TYPE_APPLICATION_INFO }; + application.pApplicationName = "llvulkantextureupload"; + application.applicationVersion = 1; + application.pEngineName = "Second Life texture upload diagnostic"; + application.engineVersion = 1; + application.apiVersion = VK_API_VERSION_1_1; + + const char* validation_layer = "VK_LAYER_KHRONOS_validation"; + VkInstanceCreateInfo create_info{ VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO }; + create_info.pNext = &debug_info; + create_info.flags = flags; + create_info.pApplicationInfo = &application; + create_info.enabledLayerCount = 1; + create_info.ppEnabledLayerNames = &validation_layer; + create_info.enabledExtensionCount = static_cast(enabled_extensions.size()); + create_info.ppEnabledExtensionNames = enabled_extensions.data(); + check(vkCreateInstance(&create_info, nullptr, &mInstance), "vkCreateInstance"); + + const auto create_debug = reinterpret_cast( + vkGetInstanceProcAddr(mInstance, "vkCreateDebugUtilsMessengerEXT")); + if (!create_debug) + { + throw Failure("vkCreateDebugUtilsMessengerEXT is unavailable after enabling VK_EXT_debug_utils"); + } + check(create_debug(mInstance, &debug_info, nullptr, &mDebugMessenger), + "vkCreateDebugUtilsMessengerEXT"); +} + +bool VulkanTextureUploadRun::hasRequiredFormats(VkPhysicalDevice physical_device) const +{ + VkFormatProperties properties{}; + vkGetPhysicalDeviceFormatProperties(physical_device, COLOR_FORMAT, &properties); + constexpr VkFormatFeatureFlags REQUIRED = VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | + VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT | + VK_FORMAT_FEATURE_BLIT_SRC_BIT | + VK_FORMAT_FEATURE_BLIT_DST_BIT | + VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | + VK_FORMAT_FEATURE_TRANSFER_SRC_BIT | + VK_FORMAT_FEATURE_TRANSFER_DST_BIT; + if ((properties.optimalTilingFeatures & REQUIRED) != REQUIRED) + { + return false; + } + VkFormatProperties vertex_properties{}; + vkGetPhysicalDeviceFormatProperties(physical_device, VK_FORMAT_R32G32B32_SFLOAT, &vertex_properties); + if ((vertex_properties.bufferFeatures & VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT) == 0) + { + return false; + } + + struct Requirement + { + Extent2D mExtent; + std::uint32_t mMips; + VkImageUsageFlags mUsage; + }; + constexpr std::array requirements{ + Requirement{ RESIDENT_EXTENT, LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS, + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT }, + Requirement{ OUTPUT_EXTENT, 1, OUTPUT_USAGE } + }; + for (const Requirement& requirement : requirements) + { + VkImageFormatProperties image_properties{}; + if (vkGetPhysicalDeviceImageFormatProperties(physical_device, COLOR_FORMAT, VK_IMAGE_TYPE_2D, + VK_IMAGE_TILING_OPTIMAL, requirement.mUsage, 0, + &image_properties) != VK_SUCCESS || + image_properties.maxExtent.width < requirement.mExtent.mWidth || + image_properties.maxExtent.height < requirement.mExtent.mHeight || + image_properties.maxMipLevels < requirement.mMips || image_properties.maxArrayLayers < 1 || + (image_properties.sampleCounts & VK_SAMPLE_COUNT_1_BIT) == 0) + { + return false; + } + } + return true; +} + +bool VulkanTextureUploadRun::hasRequiredMemory(VkPhysicalDevice physical_device) const +{ + VkPhysicalDeviceMemoryProperties properties{}; + vkGetPhysicalDeviceMemoryProperties(physical_device, &properties); + for (std::uint32_t index = 0; index < properties.memoryTypeCount; ++index) + { + if ((properties.memoryTypes[index].propertyFlags & VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT) != 0) + { + return true; + } + } + return false; +} + +std::optional VulkanTextureUploadRun::graphicsQueueFamily(VkPhysicalDevice physical_device) const +{ + std::uint32_t count = 0; + vkGetPhysicalDeviceQueueFamilyProperties(physical_device, &count, nullptr); + std::vector properties(count); + vkGetPhysicalDeviceQueueFamilyProperties(physical_device, &count, properties.data()); + for (std::uint32_t index = 0; index < count; ++index) + { + const VkQueueFlags required = VK_QUEUE_GRAPHICS_BIT | VK_QUEUE_TRANSFER_BIT; + if (properties[index].queueCount != 0 && (properties[index].queueFlags & required) == required) + { + return index; + } + } + return std::nullopt; +} + +std::vector VulkanTextureUploadRun::deviceExtensions( + VkPhysicalDevice physical_device) const +{ + return enumerate( + [physical_device](std::uint32_t* count, VkExtensionProperties* values) + { + return vkEnumerateDeviceExtensionProperties(physical_device, nullptr, count, values); + }, + "vkEnumerateDeviceExtensionProperties"); +} + +void VulkanTextureUploadRun::selectPhysicalDevice() +{ + const auto devices = enumerate( + [this](std::uint32_t* count, VkPhysicalDevice* values) + { + return vkEnumeratePhysicalDevices(mInstance, count, values); + }, + "vkEnumeratePhysicalDevices"); + for (VkPhysicalDevice device : devices) + { + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(device, &properties); + const auto queue_family = graphicsQueueFamily(device); + const auto& limits = properties.limits; + if (properties.apiVersion < VK_API_VERSION_1_1 || !queue_family || !hasRequiredFormats(device) || + !hasRequiredMemory(device) || limits.maxFramebufferWidth < OUTPUT_EXTENT.mWidth || + limits.maxFramebufferHeight < OUTPUT_EXTENT.mHeight || limits.maxImageDimension2D < RESIDENT_EXTENT.mWidth || + limits.maxVertexInputBindings < 1 || limits.maxVertexInputAttributes < 1 || + limits.maxPerStageDescriptorSamplers < 1 || limits.maxDescriptorSetSamplers < 1 || + limits.maxPerStageDescriptorSampledImages < 1 || limits.maxDescriptorSetSampledImages < 1 || + limits.maxBoundDescriptorSets < 1) + { + continue; + } + mPhysicalDevice = device; + mDeviceProperties = properties; + mQueueFamily = *queue_family; + std::uint32_t queue_family_count = 0; + vkGetPhysicalDeviceQueueFamilyProperties(device, &queue_family_count, nullptr); + std::vector queue_properties(queue_family_count); + vkGetPhysicalDeviceQueueFamilyProperties(device, &queue_family_count, queue_properties.data()); + mQueueFamilyFlags = queue_properties[mQueueFamily].queueFlags; + mPortabilitySubset = hasName(deviceExtensions(device), PORTABILITY_SUBSET_EXTENSION); + return; + } + throw CapabilityFailure("no Vulkan 1.1 graphics device supports the exact RGBA8 upload, linear blit, sample, and readback requirements"); +} + +void VulkanTextureUploadRun::createDevice() +{ + const float priority = 1.f; + VkDeviceQueueCreateInfo queue_info{ VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO }; + queue_info.queueFamilyIndex = mQueueFamily; + queue_info.queueCount = 1; + queue_info.pQueuePriorities = &priority; + std::vector extensions; + if (mPortabilitySubset) + { + extensions.push_back(PORTABILITY_SUBSET_EXTENSION); + } + VkDeviceCreateInfo create_info{ VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO }; + create_info.queueCreateInfoCount = 1; + create_info.pQueueCreateInfos = &queue_info; + create_info.enabledExtensionCount = static_cast(extensions.size()); + create_info.ppEnabledExtensionNames = extensions.data(); + check(vkCreateDevice(mPhysicalDevice, &create_info, nullptr, &mDevice), "vkCreateDevice"); + vkGetDeviceQueue(mDevice, mQueueFamily, 0, &mQueue); + if (mQueue == VK_NULL_HANDLE) + { + throw Failure("vkGetDeviceQueue returned a null graphics queue"); + } +} + +void VulkanTextureUploadRun::createCommandResources() +{ + VkCommandPoolCreateInfo pool_info{ VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO }; + pool_info.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pool_info.queueFamilyIndex = mQueueFamily; + check(vkCreateCommandPool(mDevice, &pool_info, nullptr, &mCommandPool), "vkCreateCommandPool"); + VkCommandBufferAllocateInfo allocation{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO }; + allocation.commandPool = mCommandPool; + allocation.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocation.commandBufferCount = 1; + check(vkAllocateCommandBuffers(mDevice, &allocation, &mCommandBuffer), "vkAllocateCommandBuffers"); +} + +std::uint32_t VulkanTextureUploadRun::memoryType(std::uint32_t bits, VkMemoryPropertyFlags required, + VkMemoryPropertyFlags preferred, + VkMemoryPropertyFlags& selected) const +{ + VkPhysicalDeviceMemoryProperties properties{}; + vkGetPhysicalDeviceMemoryProperties(mPhysicalDevice, &properties); + for (int pass = 0; pass < 2; ++pass) + { + for (std::uint32_t index = 0; index < properties.memoryTypeCount; ++index) + { + const VkMemoryPropertyFlags flags = properties.memoryTypes[index].propertyFlags; + if ((bits & (1U << index)) != 0 && (flags & required) == required && + (pass != 0 || (flags & preferred) == preferred)) + { + selected = flags; + return index; + } + } + } + throw Failure("no Vulkan memory type satisfies the required properties"); +} + +Buffer VulkanTextureUploadRun::createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, bool mapped) +{ + Buffer result; + result.mSize = size; + result.mUsage = usage; + VkBufferCreateInfo create_info{ VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO }; + create_info.size = size; + create_info.usage = usage; + create_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + check(vkCreateBuffer(mDevice, &create_info, nullptr, &result.mBuffer), "vkCreateBuffer"); + try + { + VkMemoryRequirements requirements{}; + vkGetBufferMemoryRequirements(mDevice, result.mBuffer, &requirements); + const VkMemoryPropertyFlags required = mapped ? VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT : 0; + const VkMemoryPropertyFlags preferred = mapped ? VK_MEMORY_PROPERTY_HOST_COHERENT_BIT : + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT; + VkMemoryPropertyFlags selected = 0; + const std::uint32_t memory_type = memoryType(requirements.memoryTypeBits, required, preferred, selected); + result.mAllocationSize = requirements.size; + result.mMemoryProperties = selected; + VkMemoryAllocateInfo allocation{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO }; + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = memory_type; + check(vkAllocateMemory(mDevice, &allocation, nullptr, &result.mMemory), "vkAllocateMemory(buffer)"); + check(vkBindBufferMemory(mDevice, result.mBuffer, result.mMemory, 0), "vkBindBufferMemory"); + if (mapped) + { + check(vkMapMemory(mDevice, result.mMemory, 0, VK_WHOLE_SIZE, 0, &result.mMapped), "vkMapMemory(buffer)"); + } + return result; + } + catch (...) + { + destroyBuffer(result); + throw; + } +} + +Image VulkanTextureUploadRun::createImage(Extent2D extent, std::uint32_t mip_levels, + VkImageUsageFlags usage) +{ + Image result; + result.mFormat = COLOR_FORMAT; + result.mExtent = extent; + result.mMipLevels = mip_levels; + result.mUsage = usage; + result.mViewRange = { VK_IMAGE_ASPECT_COLOR_BIT, 0, mip_levels, 0, 1 }; + VkImageCreateInfo create_info{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO }; + create_info.imageType = VK_IMAGE_TYPE_2D; + create_info.format = COLOR_FORMAT; + create_info.extent = { extent.mWidth, extent.mHeight, 1 }; + create_info.mipLevels = mip_levels; + create_info.arrayLayers = 1; + create_info.samples = VK_SAMPLE_COUNT_1_BIT; + create_info.tiling = VK_IMAGE_TILING_OPTIMAL; + create_info.usage = usage; + create_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + create_info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + check(vkCreateImage(mDevice, &create_info, nullptr, &result.mImage), "vkCreateImage"); + try + { + VkMemoryRequirements requirements{}; + vkGetImageMemoryRequirements(mDevice, result.mImage, &requirements); + VkMemoryPropertyFlags selected = 0; + const std::uint32_t memory_type = memoryType(requirements.memoryTypeBits, 0, + VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, selected); + result.mAllocationSize = requirements.size; + VkMemoryAllocateInfo allocation{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO }; + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = memory_type; + check(vkAllocateMemory(mDevice, &allocation, nullptr, &result.mMemory), "vkAllocateMemory(image)"); + check(vkBindImageMemory(mDevice, result.mImage, result.mMemory, 0), "vkBindImageMemory"); + VkImageViewCreateInfo view_info{ VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO }; + view_info.image = result.mImage; + view_info.viewType = VK_IMAGE_VIEW_TYPE_2D; + view_info.format = COLOR_FORMAT; + view_info.components = { VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY, + VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY }; + view_info.subresourceRange = result.mViewRange; + check(vkCreateImageView(mDevice, &view_info, nullptr, &result.mView), "vkCreateImageView"); + return result; + } + catch (...) + { + destroyImage(result); + throw; + } +} + +void VulkanTextureUploadRun::destroyBuffer(Buffer& buffer) noexcept +{ + if (buffer.mMapped && buffer.mMemory != VK_NULL_HANDLE) + { + vkUnmapMemory(mDevice, buffer.mMemory); + } + if (buffer.mBuffer != VK_NULL_HANDLE) vkDestroyBuffer(mDevice, buffer.mBuffer, nullptr); + if (buffer.mMemory != VK_NULL_HANDLE) vkFreeMemory(mDevice, buffer.mMemory, nullptr); + buffer = {}; +} + +void VulkanTextureUploadRun::destroyImage(Image& image) noexcept +{ + if (image.mView != VK_NULL_HANDLE) vkDestroyImageView(mDevice, image.mView, nullptr); + if (image.mImage != VK_NULL_HANDLE) vkDestroyImage(mDevice, image.mImage, nullptr); + if (image.mMemory != VK_NULL_HANDLE) vkFreeMemory(mDevice, image.mMemory, nullptr); + image = {}; +} + +void VulkanTextureUploadRun::writeBuffer(Buffer& buffer, const void* source, std::size_t size) +{ + if (!buffer.mMapped || size > buffer.mSize) + { + throw Failure("mapped buffer write exceeds the fixed allocation"); + } + std::memcpy(buffer.mMapped, source, size); + if ((buffer.mMemoryProperties & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0) + { + VkMappedMemoryRange range{ VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE }; + range.memory = buffer.mMemory; + range.offset = 0; + range.size = VK_WHOLE_SIZE; + check(vkFlushMappedMemoryRanges(mDevice, 1, &range), "vkFlushMappedMemoryRanges"); + } +} + +std::vector VulkanTextureUploadRun::readBuffer(const Buffer& buffer, std::size_t size) +{ + if (!buffer.mMapped || size > buffer.mSize) + { + throw Failure("mapped buffer read exceeds the fixed allocation"); + } + if ((buffer.mMemoryProperties & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) == 0) + { + VkMappedMemoryRange range{ VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE }; + range.memory = buffer.mMemory; + range.offset = 0; + range.size = VK_WHOLE_SIZE; + check(vkInvalidateMappedMemoryRanges(mDevice, 1, &range), "vkInvalidateMappedMemoryRanges"); + } + const auto* begin = static_cast(buffer.mMapped); + return { begin, begin + size }; +} + +void VulkanTextureUploadRun::submitDiagnostic(const std::function& recorder) +{ + check(vkResetCommandBuffer(mCommandBuffer, 0), "vkResetCommandBuffer(diagnostic)"); + VkCommandBufferBeginInfo begin{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO }; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + check(vkBeginCommandBuffer(mCommandBuffer, &begin), "vkBeginCommandBuffer(diagnostic)"); + recorder(mCommandBuffer); + check(vkEndCommandBuffer(mCommandBuffer), "vkEndCommandBuffer(diagnostic)"); + VkSubmitInfo submit{ VK_STRUCTURE_TYPE_SUBMIT_INFO }; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &mCommandBuffer; + check(vkQueueSubmit(mQueue, 1, &submit, VK_NULL_HANDLE), "vkQueueSubmit(diagnostic)"); + check(vkQueueWaitIdle(mQueue), "vkQueueWaitIdle(diagnostic)"); +} + +void VulkanTextureUploadRun::transition(Image& image, VkImageLayout old_layout, VkImageLayout new_layout, + VkAccessFlags source_access, VkAccessFlags destination_access, + VkPipelineStageFlags source_stage, + VkPipelineStageFlags destination_stage) +{ + submitDiagnostic([&](VkCommandBuffer command_buffer) + { + VkImageMemoryBarrier barrier{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER }; + barrier.srcAccessMask = source_access; + barrier.dstAccessMask = destination_access; + barrier.oldLayout = old_layout; + barrier.newLayout = new_layout; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.image = image.mImage; + barrier.subresourceRange = image.mViewRange; + vkCmdPipelineBarrier(command_buffer, source_stage, destination_stage, 0, + 0, nullptr, 0, nullptr, 1, &barrier); + }); + image.mLayout = new_layout; +} + +void VulkanTextureUploadRun::createResources(const TextureUploadFixture& fixture) +{ + mScreen = createBuffer(SCREEN_BYTES, VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, true); + mStaging = createBuffer(STAGING_BYTES, VK_BUFFER_USAGE_TRANSFER_SRC_BIT, true); + mReadback = createBuffer(READBACK_BYTES, VK_BUFFER_USAGE_TRANSFER_DST_BIT, true); + writeBuffer(mScreen, fixture.mScreenTriangle.data(), static_cast(SCREEN_BYTES)); + + mOld = createImage(RESIDENT_EXTENT, LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS, REPLACEMENT_USAGE); + mReplacement = createImage(RESIDENT_EXTENT, LLRenderContract::TEXTURE_UPLOAD_MIP_LEVELS, REPLACEMENT_USAGE); + mOutputImage = createImage(OUTPUT_EXTENT, 1, OUTPUT_USAGE); + + VkSamplerCreateInfo sampler_info{ VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO }; + sampler_info.magFilter = VK_FILTER_LINEAR; + sampler_info.minFilter = VK_FILTER_LINEAR; + sampler_info.mipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + sampler_info.addressModeU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampler_info.addressModeV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampler_info.addressModeW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampler_info.mipLodBias = 0.f; + sampler_info.anisotropyEnable = VK_FALSE; + sampler_info.maxAnisotropy = 1.f; + sampler_info.compareEnable = VK_FALSE; + sampler_info.compareOp = VK_COMPARE_OP_ALWAYS; + sampler_info.minLod = 0.f; + sampler_info.maxLod = 2.f; + sampler_info.borderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK; + sampler_info.unnormalizedCoordinates = VK_FALSE; + check(vkCreateSampler(mDevice, &sampler_info, nullptr, &mSampler), "vkCreateSampler"); +} + +void VulkanTextureUploadRun::createDescriptors() +{ + VkDescriptorSetLayoutBinding binding{}; + binding.binding = 0; + binding.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + binding.descriptorCount = 1; + binding.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + VkDescriptorSetLayoutCreateInfo layout_info{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO }; + layout_info.bindingCount = 1; + layout_info.pBindings = &binding; + check(vkCreateDescriptorSetLayout(mDevice, &layout_info, nullptr, &mDescriptorSetLayout), + "vkCreateDescriptorSetLayout"); + + VkPipelineLayoutCreateInfo pipeline_layout_info{ VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO }; + pipeline_layout_info.setLayoutCount = 1; + pipeline_layout_info.pSetLayouts = &mDescriptorSetLayout; + check(vkCreatePipelineLayout(mDevice, &pipeline_layout_info, nullptr, &mPipelineLayout), + "vkCreatePipelineLayout"); + + VkDescriptorPoolSize pool_size{ VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, 1 }; + VkDescriptorPoolCreateInfo pool_info{ VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO }; + pool_info.maxSets = 1; + pool_info.poolSizeCount = 1; + pool_info.pPoolSizes = &pool_size; + check(vkCreateDescriptorPool(mDevice, &pool_info, nullptr, &mDescriptorPool), "vkCreateDescriptorPool"); + VkDescriptorSetAllocateInfo allocation{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO }; + allocation.descriptorPool = mDescriptorPool; + allocation.descriptorSetCount = 1; + allocation.pSetLayouts = &mDescriptorSetLayout; + check(vkAllocateDescriptorSets(mDevice, &allocation, &mDescriptorSet), "vkAllocateDescriptorSets"); + + VkDescriptorImageInfo image_info{}; + image_info.sampler = mSampler; + image_info.imageView = mReplacement.mView; + image_info.imageLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + VkWriteDescriptorSet write{ VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET }; + write.dstSet = mDescriptorSet; + write.dstBinding = 0; + write.descriptorCount = 1; + write.descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + write.pImageInfo = &image_info; + vkUpdateDescriptorSets(mDevice, 1, &write, 0, nullptr); +} + +void VulkanTextureUploadRun::createRenderPass() +{ + VkAttachmentDescription attachment{}; + attachment.format = COLOR_FORMAT; + attachment.samples = VK_SAMPLE_COUNT_1_BIT; + attachment.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachment.initialLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + attachment.finalLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + VkAttachmentReference reference{ 0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL }; + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &reference; + VkRenderPassCreateInfo create_info{ VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO }; + create_info.attachmentCount = 1; + create_info.pAttachments = &attachment; + create_info.subpassCount = 1; + create_info.pSubpasses = &subpass; + check(vkCreateRenderPass(mDevice, &create_info, nullptr, &mRenderPass), "vkCreateRenderPass"); + + VkFramebufferCreateInfo framebuffer_info{ VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO }; + framebuffer_info.renderPass = mRenderPass; + framebuffer_info.attachmentCount = 1; + framebuffer_info.pAttachments = &mOutputImage.mView; + framebuffer_info.width = OUTPUT_EXTENT.mWidth; + framebuffer_info.height = OUTPUT_EXTENT.mHeight; + framebuffer_info.layers = 1; + check(vkCreateFramebuffer(mDevice, &framebuffer_info, nullptr, &mFramebuffer), "vkCreateFramebuffer"); +} + +void VulkanTextureUploadRun::createPipeline() +{ + const auto create_module = [this](const std::vector& words) + { + VkShaderModule module = VK_NULL_HANDLE; + VkShaderModuleCreateInfo create_info{ VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO }; + create_info.codeSize = words.size() * sizeof(std::uint32_t); + create_info.pCode = words.data(); + check(vkCreateShaderModule(mDevice, &create_info, nullptr, &module), "vkCreateShaderModule"); + return module; + }; + VkShaderModule vertex = create_module(mVertexSpirv); + VkShaderModule fragment = VK_NULL_HANDLE; + try + { + fragment = create_module(mFragmentSpirv); + std::array stages{}; + stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT; + stages[0].module = vertex; + stages[0].pName = "main"; + stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT; + stages[1].module = fragment; + stages[1].pName = "main"; + + VkVertexInputBindingDescription vertex_binding{ 0, 16, VK_VERTEX_INPUT_RATE_VERTEX }; + VkVertexInputAttributeDescription vertex_attribute{ 0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0 }; + VkPipelineVertexInputStateCreateInfo vertex_input{ VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO }; + vertex_input.vertexBindingDescriptionCount = 1; + vertex_input.pVertexBindingDescriptions = &vertex_binding; + vertex_input.vertexAttributeDescriptionCount = 1; + vertex_input.pVertexAttributeDescriptions = &vertex_attribute; + VkPipelineInputAssemblyStateCreateInfo assembly{ VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO }; + assembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + VkPipelineViewportStateCreateInfo viewport{ VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO }; + viewport.viewportCount = 1; + viewport.scissorCount = 1; + VkPipelineRasterizationStateCreateInfo raster{ VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO }; + raster.polygonMode = VK_POLYGON_MODE_FILL; + raster.cullMode = VK_CULL_MODE_NONE; + raster.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + raster.lineWidth = 1.f; + VkPipelineMultisampleStateCreateInfo multisample{ VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO }; + multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + VkPipelineDepthStencilStateCreateInfo depth{ VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO }; + depth.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + depth.minDepthBounds = 0.f; + depth.maxDepthBounds = 1.f; + VkPipelineColorBlendAttachmentState blend{}; + blend.srcColorBlendFactor = VK_BLEND_FACTOR_ONE; + blend.dstColorBlendFactor = VK_BLEND_FACTOR_ZERO; + blend.colorBlendOp = VK_BLEND_OP_ADD; + blend.srcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + blend.dstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; + blend.alphaBlendOp = VK_BLEND_OP_ADD; + blend.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + VkPipelineColorBlendStateCreateInfo color{ VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO }; + color.logicOp = VK_LOGIC_OP_COPY; + color.attachmentCount = 1; + color.pAttachments = &blend; + constexpr std::array dynamic_states{ VK_DYNAMIC_STATE_VIEWPORT, VK_DYNAMIC_STATE_SCISSOR }; + VkPipelineDynamicStateCreateInfo dynamic{ VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO }; + dynamic.dynamicStateCount = static_cast(dynamic_states.size()); + dynamic.pDynamicStates = dynamic_states.data(); + + VkGraphicsPipelineCreateInfo pipeline_info{ VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO }; + pipeline_info.stageCount = static_cast(stages.size()); + pipeline_info.pStages = stages.data(); + pipeline_info.pVertexInputState = &vertex_input; + pipeline_info.pInputAssemblyState = &assembly; + pipeline_info.pViewportState = &viewport; + pipeline_info.pRasterizationState = &raster; + pipeline_info.pMultisampleState = &multisample; + pipeline_info.pDepthStencilState = &depth; + pipeline_info.pColorBlendState = &color; + pipeline_info.pDynamicState = &dynamic; + pipeline_info.layout = mPipelineLayout; + pipeline_info.renderPass = mRenderPass; + pipeline_info.subpass = 0; + check(vkCreateGraphicsPipelines(mDevice, VK_NULL_HANDLE, 1, &pipeline_info, nullptr, &mPipeline), + "vkCreateGraphicsPipelines"); + } + catch (...) + { + if (fragment != VK_NULL_HANDLE) vkDestroyShaderModule(mDevice, fragment, nullptr); + vkDestroyShaderModule(mDevice, vertex, nullptr); + throw; + } + vkDestroyShaderModule(mDevice, fragment, nullptr); + vkDestroyShaderModule(mDevice, vertex, nullptr); +} + +void VulkanTextureUploadRun::seedImages(const TextureUploadFixture& fixture) +{ + const auto seed = [this](Image& image, const std::uint8_t* bytes) + { + for (std::uint32_t mip = 0; mip < image.mMipLevels; ++mip) + { + const std::uint32_t width = std::max(1U, image.mExtent.mWidth >> mip); + const std::uint32_t height = std::max(1U, image.mExtent.mHeight >> mip); + const std::size_t size = static_cast(width) * height * 4; + const std::size_t offset = image.mMipLevels == 1 ? 0 : + LLRenderContract::TEXTURE_UPLOAD_MIP_BYTE_OFFSETS[mip]; + writeBuffer(mStaging, bytes + offset, size); + submitDiagnostic([this, &image, mip, width, height, size](VkCommandBuffer command_buffer) + { + VkBufferMemoryBarrier buffer_barrier{ VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER }; + buffer_barrier.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT; + buffer_barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + buffer_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + buffer_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + buffer_barrier.buffer = mStaging.mBuffer; + buffer_barrier.size = size; + VkImageMemoryBarrier image_barrier{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER }; + image_barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + image_barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + image_barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + image_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + image_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + image_barrier.image = image.mImage; + image_barrier.subresourceRange = { VK_IMAGE_ASPECT_COLOR_BIT, mip, 1, 0, 1 }; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_HOST_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 0, nullptr, 1, &buffer_barrier, 1, &image_barrier); + VkBufferImageCopy copy{}; + copy.imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, mip, 0, 1 }; + copy.imageExtent = { width, height, 1 }; + vkCmdCopyBufferToImage(command_buffer, mStaging.mBuffer, image.mImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©); + }); + } + transition(image, VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL, + VK_ACCESS_TRANSFER_WRITE_BIT, VK_ACCESS_SHADER_READ_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT); + }; + seed(mOld, fixture.mOldMipRGBA8.data()); + seed(mReplacement, fixture.mReplacementSentinelMipRGBA8.data()); + seed(mOutputImage, fixture.mOutputSentinelRGBA8.data()); +} + +std::vector VulkanTextureUploadRun::snapshotImage(Image& image) +{ + std::size_t total = 0; + for (std::uint32_t mip = 0; mip < image.mMipLevels; ++mip) + { + total += static_cast(std::max(1U, image.mExtent.mWidth >> mip)) * + std::max(1U, image.mExtent.mHeight >> mip) * 4; + } + if (total > READBACK_BYTES) + { + throw Failure("image snapshot exceeds the fixed readback buffer"); + } + submitDiagnostic([this, &image](VkCommandBuffer command_buffer) + { + VkImageMemoryBarrier to_transfer{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER }; + to_transfer.srcAccessMask = VK_ACCESS_SHADER_READ_BIT; + to_transfer.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + to_transfer.oldLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + to_transfer.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + to_transfer.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_transfer.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + to_transfer.image = image.mImage; + to_transfer.subresourceRange = image.mViewRange; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 0, nullptr, 0, nullptr, 1, &to_transfer); + std::array copies{}; + VkDeviceSize offset = 0; + for (std::uint32_t mip = 0; mip < image.mMipLevels; ++mip) + { + const std::uint32_t width = std::max(1U, image.mExtent.mWidth >> mip); + const std::uint32_t height = std::max(1U, image.mExtent.mHeight >> mip); + copies[mip].bufferOffset = offset; + copies[mip].imageSubresource = { VK_IMAGE_ASPECT_COLOR_BIT, mip, 0, 1 }; + copies[mip].imageExtent = { width, height, 1 }; + offset += static_cast(width) * height * 4; + } + vkCmdCopyImageToBuffer(command_buffer, image.mImage, VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + mReadback.mBuffer, image.mMipLevels, copies.data()); + VkImageMemoryBarrier restore = to_transfer; + restore.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + restore.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + restore.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + restore.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + VkBufferMemoryBarrier host{ VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER }; + host.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + host.dstAccessMask = VK_ACCESS_HOST_READ_BIT; + host.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host.buffer = mReadback.mBuffer; + host.size = VK_WHOLE_SIZE; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT | VK_PIPELINE_STAGE_HOST_BIT, 0, + 0, nullptr, 1, &host, 1, &restore); + }); + return readBuffer(mReadback, total); +} + +FrameSnapshot VulkanTextureUploadRun::mutateFrame(const TextureUploadCase& diagnostic_case, + PacketMutation mutation) const +{ + FrameSnapshot frame = diagnostic_case.mFrame; + if (mutation == PacketMutation::None) + { + return frame; + } + auto& upload = frame.mUploads.front(); + auto& pass = frame.mPasses.front(); + switch (mutation) + { + case PacketMutation::None: break; + case PacketMutation::WrongFrame: + frame.mFrame = 0; + break; + case PacketMutation::WrongUploadDestinationGeneration: + ++upload.mDestination.mGeneration; + break; + case PacketMutation::WrongOldPacketGeneration: + ++frame.mImages[0].mHandle.mGeneration; + break; + case PacketMutation::WrongOutputPacketGeneration: + ++frame.mImages[2].mHandle.mGeneration; + break; + case PacketMutation::PacketImageAlias: + frame.mImages[2].mHandle = frame.mImages[0].mHandle; + break; + case PacketMutation::WrongOldPacketMipCount: + --frame.mImages[0].mMipLevels; + break; + case PacketMutation::WrongReplacementPacketMipCount: + --frame.mImages[1].mMipLevels; + break; + case PacketMutation::WrongOutputPacketMipCount: + ++frame.mImages[2].mMipLevels; + break; + case PacketMutation::DuplicateRevision: + upload.mRevision = LLRenderContract::TEXTURE_UPLOAD_PRIOR_REVISION; + break; + case PacketMutation::WrongSubresource: + upload.mSubresource.mMipLevel = 1; + break; + case PacketMutation::WrongOffset: + upload.mOffset.mX = 1; + break; + case PacketMutation::WrongResidentExtent: + --upload.mExtent.mWidth; + break; + case PacketMutation::WrongLogicalExtent: + --upload.mLogicalExtent.mWidth; + break; + case PacketMutation::WrongResidentDiscard: + --upload.mResidentDiscard; + break; + case PacketMutation::WrongRowPitch: + --upload.mRowPitch; + break; + case PacketMutation::WrongRowOrigin: + upload.mRowOrigin = LLRenderContract::RowOrigin::BottomLeft; + break; + case PacketMutation::WrongSourceFormat: + upload.mSourceFormat = LLRenderContract::PixelFormat::RGB8Unorm; + break; + case PacketMutation::WrongMipGeneration: + upload.mMipGeneration = LLRenderContract::MipGeneration::Disabled; + break; + case PacketMutation::WrongBefore: + upload.mBefore = LLRenderContract::ImageState::ShaderRead; + break; + case PacketMutation::WrongDuring: + upload.mDuring = LLRenderContract::ImageState::ShaderRead; + break; + case PacketMutation::WrongAfter: + upload.mAfter = LLRenderContract::ImageState::ColorAttachment; + break; + case PacketMutation::WrongPixelOffset: + upload.mPixels.mOffset = 1; + break; + case PacketMutation::WrongPixelSize: + --upload.mPixels.mSize; + break; + case PacketMutation::WrongPixelStorageSize: + { + auto storage = std::make_shared>(*upload.mPixels.mStorage); + storage->push_back(0xa5U); + upload.mPixels.mStorage = std::move(storage); + break; + } + case PacketMutation::WrongSamplerPacket: + frame.mSamplers[0].mAddressU = LLRenderContract::AddressMode::Repeat; + break; + case PacketMutation::MissingSamplerPacket: + frame.mSamplers.clear(); + break; + case PacketMutation::WrongPipelinePacket: + ++frame.mPipelines[0].mProgram.mVariant; + break; + case PacketMutation::MissingPipelinePacket: + frame.mPipelines.clear(); + break; + case PacketMutation::WrongOutputPacketExtent: + --frame.mImages[2].mExtent.mWidth; + break; + case PacketMutation::WrongReleaseFrame: + ++frame.mReleases[0].mFrame; + break; + case PacketMutation::WrongRelease: + frame.mReleases.front().mResource = LLRenderContract::ResourceHandle{ + diagnostic_case.mInputs.mHandles.mReplacementImage }; + break; + case PacketMutation::MissingRelease: + frame.mReleases.clear(); + break; + case PacketMutation::ExtraPass: + frame.mPasses.push_back(frame.mPasses[0]); + break; + case PacketMutation::MissingPass: + frame.mPasses.clear(); + break; + case PacketMutation::WrongViewport: + --pass.mViewport.mWidth; + break; + case PacketMutation::MissingDraw: + pass.mDraws.clear(); + break; + case PacketMutation::ExtraDraw: + pass.mDraws.push_back(pass.mDraws[0]); + break; + case PacketMutation::WrongSampleRange: + { + auto& draw = std::get(pass.mDraws.front()); + --draw.mResources.mSampledImages.front().mRange.mMipLevelCount; + break; + } + } + return frame; +} + +LLRenderVulkanTextureUpload::ExecutionContext VulkanTextureUploadRun::executionContext( + ContextMutation mutation) +{ + LLRenderVulkanTextureUpload::ExecutionContext context; + context.mDevice = mDevice; + context.mCommandPool = mCommandPool; + context.mCommandBuffer = mCommandBuffer; + context.mQueue = mQueue; + context.mOwnershipToken = mOwnershipToken; + context.mQueueFamilyIndex = mQueueFamily; + context.mQueueFamilyFlags = mQueueFamilyFlags; + context.mQueueCount = 1; + context.mQueueIndex = 0; + context.mCommandPoolQueueFamilyIndex = mQueueFamily; + context.mCommandPoolFlags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + context.mCommandBufferLevel = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + context.mRecordingAttemptCount = &mExecutorRecordings; + context.mSubmissionCount = &mExecutorSubmissions; + context.mRequiredVertexShaderIdentity = mVertexIdentity; + context.mRequiredFragmentShaderIdentity = mFragmentIdentity; + switch (mutation) + { + case ContextMutation::None: break; + case ContextMutation::NullDevice: context.mDevice = VK_NULL_HANDLE; break; + case ContextMutation::NullCommandBuffer: context.mCommandBuffer = VK_NULL_HANDLE; break; + case ContextMutation::NullQueue: context.mQueue = VK_NULL_HANDLE; break; + case ContextMutation::MissingRecordingCounter: context.mRecordingAttemptCount = nullptr; break; + case ContextMutation::MissingSubmissionCounter: context.mSubmissionCount = nullptr; break; + case ContextMutation::WrongVertexIdentity: ++context.mRequiredVertexShaderIdentity.front(); break; + case ContextMutation::WrongFragmentIdentity: ++context.mRequiredFragmentShaderIdentity.front(); break; + case ContextMutation::NullCommandPool: context.mCommandPool = VK_NULL_HANDLE; break; + case ContextMutation::WrongOwnershipToken: ++context.mOwnershipToken; break; + case ContextMutation::IgnoredQueueFamily: context.mQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; break; + case ContextMutation::MissingGraphicsQueueFlag: context.mQueueFamilyFlags &= ~VK_QUEUE_GRAPHICS_BIT; break; + case ContextMutation::MissingTransferQueueFlag: context.mQueueFamilyFlags &= ~VK_QUEUE_TRANSFER_BIT; break; + case ContextMutation::WrongQueueCount: ++context.mQueueCount; break; + case ContextMutation::WrongQueueIndex: ++context.mQueueIndex; break; + case ContextMutation::WrongCommandPoolFamily: ++context.mCommandPoolQueueFamilyIndex; break; + case ContextMutation::MissingCommandPoolReset: + context.mCommandPoolFlags &= ~VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + break; + case ContextMutation::WrongCommandBufferLevel: + context.mCommandBufferLevel = VK_COMMAND_BUFFER_LEVEL_SECONDARY; + break; + } + return context; +} + +LLRenderVulkanTextureUpload::Registry VulkanTextureUploadRun::makeRegistry( + const TextureUploadCase& diagnostic_case, RegistryMutation mutation, + StreamingUploadLifecycle* lifecycle) +{ + const TextureUploadFixture fixture = LLRenderContract::makeTextureUploadFixture(); + const auto& handles = diagnostic_case.mInputs.mHandles; + auto screen_handle = handles.mScreenTriangle; + auto old_handle = handles.mOldImage; + auto replacement_handle = handles.mReplacementImage; + auto output_handle = handles.mOutput; + auto sampler_handle = handles.mSampler; + auto pipeline_handle = handles.mPipeline; + if (mutation == RegistryMutation::StaleScreen) ++screen_handle.mGeneration; + if (mutation == RegistryMutation::StaleOld) ++old_handle.mGeneration; + if (mutation == RegistryMutation::StaleReplacement) ++replacement_handle.mGeneration; + if (mutation == RegistryMutation::StaleOutput) ++output_handle.mGeneration; + if (mutation == RegistryMutation::StaleSampler) ++sampler_handle.mGeneration; + if (mutation == RegistryMutation::StalePipeline) ++pipeline_handle.mGeneration; + + LLRenderVulkanTextureUpload::BufferBinding screen; + screen.mBuffer = mScreen.mBuffer; + screen.mMemory = mScreen.mMemory; + screen.mOwnershipToken = mOwnershipToken; + screen.mMapped = mScreen.mMapped; + screen.mSize = mScreen.mSize; + screen.mAllocationSize = mScreen.mAllocationSize; + screen.mMemoryOffset = 0; + screen.mCreateFlags = 0; + screen.mUsage = mScreen.mUsage; + screen.mSharingMode = VK_SHARING_MODE_EXCLUSIVE; + screen.mMemoryProperties = mScreen.mMemoryProperties; + screen.mHasFixtureBytes = true; + std::memcpy(screen.mFixtureBytes.data(), fixture.mScreenTriangle.data(), screen.mFixtureBytes.size()); + if (mutation == RegistryMutation::WrongScreenSize) --screen.mSize; + if (mutation == RegistryMutation::WrongScreenUsage) screen.mUsage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + if (mutation == RegistryMutation::WrongOwnershipToken) ++screen.mOwnershipToken; + + const auto image_binding = [this](const Image& image, bool output) + { + LLRenderVulkanTextureUpload::ImageBinding binding; + binding.mImage = image.mImage; + binding.mView = image.mView; + binding.mMemory = image.mMemory; + binding.mOwnershipToken = mOwnershipToken; + binding.mAllocationSize = image.mAllocationSize; + binding.mMemoryOffset = 0; + binding.mCreateFlags = 0; + binding.mImageType = VK_IMAGE_TYPE_2D; + binding.mFormat = image.mFormat; + binding.mResidentExtent = image.mExtent; + binding.mLogicalExtent = output ? image.mExtent : + Extent2D{ LLRenderContract::TEXTURE_UPLOAD_LOGICAL_WIDTH, + LLRenderContract::TEXTURE_UPLOAD_LOGICAL_HEIGHT }; + binding.mResidentDiscard = output ? 0 : LLRenderContract::TEXTURE_UPLOAD_RESIDENT_DISCARD; + binding.mMipLevels = image.mMipLevels; + binding.mArrayLayers = 1; + binding.mSamples = VK_SAMPLE_COUNT_1_BIT; + binding.mTiling = VK_IMAGE_TILING_OPTIMAL; + binding.mUsage = image.mUsage; + binding.mSharingMode = VK_SHARING_MODE_EXCLUSIVE; + binding.mAspect = VK_IMAGE_ASPECT_COLOR_BIT; + binding.mLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + binding.mViewType = VK_IMAGE_VIEW_TYPE_2D; + binding.mViewFormat = image.mFormat; + binding.mViewComponents = { VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY, + VK_COMPONENT_SWIZZLE_IDENTITY, VK_COMPONENT_SWIZZLE_IDENTITY }; + binding.mViewRange = image.mViewRange; + return binding; + }; + auto old_image = image_binding(mOld, false); + auto replacement_image = image_binding(mReplacement, false); + auto output_image = image_binding(mOutputImage, true); + old_image.mHasPreExecutionMipSnapshot = true; + old_image.mPreExecutionMipRGBA8 = mOldPreExecutionSnapshot; + if (mutation == RegistryMutation::WrongOldFormat) old_image.mFormat = VK_FORMAT_B8G8R8A8_UNORM; + if (mutation == RegistryMutation::WrongOldExtent) --old_image.mResidentExtent.mWidth; + if (mutation == RegistryMutation::WrongOldMips) --old_image.mMipLevels; + if (mutation == RegistryMutation::WrongOldUsage) old_image.mUsage &= ~VK_IMAGE_USAGE_SAMPLED_BIT; + if (mutation == RegistryMutation::WrongOldLayout) old_image.mLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + if (mutation == RegistryMutation::WrongOldViewRange) --old_image.mViewRange.levelCount; + if (mutation == RegistryMutation::WrongOldSnapshot) ++old_image.mPreExecutionMipRGBA8.front(); + if (mutation == RegistryMutation::WrongReplacementFormat) replacement_image.mFormat = VK_FORMAT_B8G8R8A8_UNORM; + if (mutation == RegistryMutation::WrongReplacementExtent) --replacement_image.mResidentExtent.mWidth; + if (mutation == RegistryMutation::WrongReplacementMips) --replacement_image.mMipLevels; + if (mutation == RegistryMutation::WrongReplacementUsage) replacement_image.mUsage &= ~VK_IMAGE_USAGE_TRANSFER_SRC_BIT; + if (mutation == RegistryMutation::WrongReplacementLayout) replacement_image.mLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + if (mutation == RegistryMutation::WrongReplacementViewRange) --replacement_image.mViewRange.levelCount; + if (mutation == RegistryMutation::WrongOutputFormat) output_image.mFormat = VK_FORMAT_B8G8R8A8_UNORM; + if (mutation == RegistryMutation::WrongOutputExtent) --output_image.mResidentExtent.mWidth; + if (mutation == RegistryMutation::WrongOutputMips) ++output_image.mMipLevels; + if (mutation == RegistryMutation::WrongOutputUsage) output_image.mUsage &= ~VK_IMAGE_USAGE_SAMPLED_BIT; + if (mutation == RegistryMutation::WrongOutputLayout) output_image.mLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + if (mutation == RegistryMutation::WrongOutputViewRange) ++output_image.mViewRange.levelCount; + if (mutation == RegistryMutation::AliasImages) replacement_image.mImage = old_image.mImage; + if (mutation == RegistryMutation::AliasImageMemory) replacement_image.mMemory = old_image.mMemory; + if (mutation == RegistryMutation::AliasViews) replacement_image.mView = old_image.mView; + + LLRenderVulkanTextureUpload::SamplerBinding sampler; + sampler.mSampler = mSampler; + sampler.mOwnershipToken = mOwnershipToken; + sampler.mCreateFlags = 0; + sampler.mMinFilter = VK_FILTER_LINEAR; + sampler.mMagFilter = VK_FILTER_LINEAR; + sampler.mMipmapMode = VK_SAMPLER_MIPMAP_MODE_LINEAR; + sampler.mAddressU = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampler.mAddressV = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampler.mAddressW = VK_SAMPLER_ADDRESS_MODE_CLAMP_TO_EDGE; + sampler.mMipLodBias = 0.f; + sampler.mAnisotropyEnable = VK_FALSE; + sampler.mMaxAnisotropy = 1.f; + sampler.mCompareEnable = VK_FALSE; + sampler.mCompareOp = VK_COMPARE_OP_ALWAYS; + sampler.mMinLod = 0.f; + sampler.mMaxLod = 2.f; + sampler.mBorderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK; + sampler.mUnnormalizedCoordinates = VK_FALSE; + if (mutation == RegistryMutation::WrongSampler) sampler.mMipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + + LLRenderVulkanTextureUpload::PipelineBinding pipeline; + pipeline.mProgram = { "contract.sample-texture", 0 }; + pipeline.mPipeline = mPipeline; + pipeline.mLayout = mPipelineLayout; + pipeline.mRenderPass = mRenderPass; + pipeline.mFramebuffer = mFramebuffer; + pipeline.mDescriptorSet = mDescriptorSet; + pipeline.mOwnershipToken = mOwnershipToken; + pipeline.mExtent = OUTPUT_EXTENT; + pipeline.mSampledDescriptor = { 0, 0, VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER, + VK_SHADER_STAGE_FRAGMENT_BIT, mReplacement.mView, mSampler, + VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL }; + pipeline.mColorView = mOutputImage.mView; + pipeline.mColorFormat = COLOR_FORMAT; + pipeline.mColorSamples = VK_SAMPLE_COUNT_1_BIT; + pipeline.mColorLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + pipeline.mColorStoreOp = VK_ATTACHMENT_STORE_OP_STORE; + pipeline.mStencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + pipeline.mStencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + pipeline.mColorInitialLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + pipeline.mColorFinalLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + pipeline.mSubpassDependencyCount = 0; + pipeline.mVertexBinding = { 0, 16, VK_VERTEX_INPUT_RATE_VERTEX }; + pipeline.mVertexAttribute = { 0, 0, VK_FORMAT_R32G32B32_SFLOAT, 0 }; + pipeline.mRaster.mTopology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + pipeline.mRaster.mPrimitiveRestartEnable = VK_FALSE; + pipeline.mRaster.mDepthClampEnable = VK_FALSE; + pipeline.mRaster.mRasterizerDiscardEnable = VK_FALSE; + pipeline.mRaster.mPolygonMode = VK_POLYGON_MODE_FILL; + pipeline.mRaster.mCullMode = VK_CULL_MODE_NONE; + pipeline.mRaster.mFrontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + pipeline.mRaster.mDepthBiasEnable = VK_FALSE; + pipeline.mRaster.mLineWidth = 1.f; + pipeline.mMultisample.mRasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + pipeline.mMultisample.mSampleShadingEnable = VK_FALSE; + pipeline.mMultisample.mMinSampleShading = 0.f; + pipeline.mMultisample.mSampleMask = 0xffffffffU; + pipeline.mMultisample.mAlphaToCoverageEnable = VK_FALSE; + pipeline.mMultisample.mAlphaToOneEnable = VK_FALSE; + pipeline.mDepthStencil.mDepthTestEnable = VK_FALSE; + pipeline.mDepthStencil.mDepthWriteEnable = VK_FALSE; + pipeline.mDepthStencil.mDepthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + pipeline.mDepthStencil.mDepthBoundsTestEnable = VK_FALSE; + pipeline.mDepthStencil.mStencilTestEnable = VK_FALSE; + pipeline.mDepthStencil.mMinDepthBounds = 0.f; + pipeline.mDepthStencil.mMaxDepthBounds = 1.f; + pipeline.mColorTarget.mFormat = COLOR_FORMAT; + pipeline.mColorTarget.mBlendEnable = VK_FALSE; + pipeline.mColorTarget.mSrcColorBlendFactor = VK_BLEND_FACTOR_ONE; + pipeline.mColorTarget.mDstColorBlendFactor = VK_BLEND_FACTOR_ZERO; + pipeline.mColorTarget.mColorBlendOp = VK_BLEND_OP_ADD; + pipeline.mColorTarget.mSrcAlphaBlendFactor = VK_BLEND_FACTOR_ONE; + pipeline.mColorTarget.mDstAlphaBlendFactor = VK_BLEND_FACTOR_ZERO; + pipeline.mColorTarget.mAlphaBlendOp = VK_BLEND_OP_ADD; + pipeline.mColorTarget.mWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + pipeline.mLogicOpEnable = VK_FALSE; + pipeline.mLogicOp = VK_LOGIC_OP_COPY; + pipeline.mDynamicViewport = VK_TRUE; + pipeline.mDynamicScissor = VK_TRUE; + pipeline.mVertexShaderIdentity = mVertexIdentity; + pipeline.mFragmentShaderIdentity = mFragmentIdentity; + if (mutation == RegistryMutation::WrongProgram) pipeline.mProgram.mName = "wrong.sample-texture"; + if (mutation == RegistryMutation::WrongVariant) ++pipeline.mProgram.mVariant; + if (mutation == RegistryMutation::WrongPipelineExtent) --pipeline.mExtent.mWidth; + if (mutation == RegistryMutation::WrongDescriptor) pipeline.mSampledDescriptor.mView = mOld.mView; + if (mutation == RegistryMutation::WrongRenderPass) pipeline.mRenderPass = VK_NULL_HANDLE; + if (mutation == RegistryMutation::WrongFramebuffer) pipeline.mFramebuffer = VK_NULL_HANDLE; + if (mutation == RegistryMutation::WrongVertexState) pipeline.mVertexBinding.mStride = 12; + if (mutation == RegistryMutation::WrongViewportPolicy) pipeline.mDynamicViewport = VK_FALSE; + if (mutation == RegistryMutation::WrongRasterState) pipeline.mRaster.mCullMode = VK_CULL_MODE_BACK_BIT; + if (mutation == RegistryMutation::WrongShaderIdentity) ++pipeline.mFragmentShaderIdentity.front(); + + const auto buffer_binding = [this](const Buffer& buffer) + { + LLRenderVulkanTextureUpload::BufferBinding binding; + binding.mBuffer = buffer.mBuffer; + binding.mMemory = buffer.mMemory; + binding.mOwnershipToken = mOwnershipToken; + binding.mMapped = buffer.mMapped; + binding.mSize = buffer.mSize; + binding.mAllocationSize = buffer.mAllocationSize; + binding.mMemoryOffset = 0; + binding.mCreateFlags = 0; + binding.mUsage = buffer.mUsage; + binding.mSharingMode = VK_SHARING_MODE_EXCLUSIVE; + binding.mMemoryProperties = buffer.mMemoryProperties; + return binding; + }; + LLRenderVulkanTextureUpload::TransferResources transfer{ buffer_binding(mStaging), buffer_binding(mReadback) }; + if (mutation == RegistryMutation::WrongStagingSize) --transfer.mStaging.mSize; + if (mutation == RegistryMutation::WrongStagingUsage) transfer.mStaging.mUsage = VK_BUFFER_USAGE_TRANSFER_DST_BIT; + if (mutation == RegistryMutation::WrongStagingMemory) transfer.mStaging.mMemoryProperties &= ~VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT; + if (mutation == RegistryMutation::WrongReadbackSize) --transfer.mReadback.mSize; + if (mutation == RegistryMutation::WrongReadbackUsage) transfer.mReadback.mUsage = VK_BUFFER_USAGE_TRANSFER_SRC_BIT; + if (mutation == RegistryMutation::WrongReadbackMemory) transfer.mReadback.mMemoryProperties &= ~VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT; + if (mutation == RegistryMutation::AliasBuffers) transfer.mReadback.mBuffer = transfer.mStaging.mBuffer; + if (mutation == RegistryMutation::AliasBufferMemory) transfer.mReadback.mMemory = transfer.mStaging.mMemory; + + if (lifecycle) + { + if (mutation == RegistryMutation::WrongLifecycleCurrent) lifecycle->mCurrentImage = handles.mReplacementImage; + if (mutation == RegistryMutation::WrongLifecycleRevision) lifecycle->mLastRevision = LLRenderContract::TEXTURE_UPLOAD_REVISION; + if (mutation == RegistryMutation::PendingLifecycle) lifecycle->mCompletionPending = true; + if (mutation == RegistryMutation::PriorCompletion) + { + lifecycle->mCompletionCount = 1; + lifecycle->mCompletedDestination = handles.mOldImage; + lifecycle->mCompletedRevision = LLRenderContract::TEXTURE_UPLOAD_PRIOR_REVISION; + lifecycle->mCompletedFrame = diagnostic_case.mInputs.mFrame; + } + if (mutation == RegistryMutation::PriorRetirement) + { + lifecycle->mRetirementCount = 1; + lifecycle->mRetiredResource = handles.mOldImage; + lifecycle->mRetirementFrame = diagnostic_case.mInputs.mFrame; + } + } + + LLRenderVulkanTextureUpload::Registry registry; + const bool registered = registry.addScreenTriangle(screen_handle, screen) && + registry.addImageGenerations(old_handle, old_image, replacement_handle, replacement_image) && + registry.addOutput(output_handle, output_image) && + registry.addSampler(sampler_handle, sampler) && + registry.addPipeline(pipeline_handle, pipeline) && + registry.addTransferResources(transfer); + if (!registered) + { + if (mutation == RegistryMutation::None) + { + throw Failure("could not register complete Vulkan texture-upload resources"); + } + return registry; + } + if (mutation != RegistryMutation::MissingLifecycle && !registry.addLifecycle(lifecycle)) + { + if (mutation == RegistryMutation::None) + { + throw Failure("could not register Vulkan texture-upload lifecycle"); + } + } + return registry; +} + +void VulkanTextureUploadRun::runRejections(const TextureUploadCase& diagnostic_case, + const TextureUploadFixture& fixture) +{ + constexpr std::array REJECTIONS{ + RejectionSpec{ "stale_screen", RegistryMutation::StaleScreen }, + RejectionSpec{ "stale_old", RegistryMutation::StaleOld }, + RejectionSpec{ "stale_replacement", RegistryMutation::StaleReplacement }, + RejectionSpec{ "stale_output", RegistryMutation::StaleOutput }, + RejectionSpec{ "stale_sampler", RegistryMutation::StaleSampler }, + RejectionSpec{ "stale_pipeline", RegistryMutation::StalePipeline }, + RejectionSpec{ "wrong_screen_size", RegistryMutation::WrongScreenSize }, + RejectionSpec{ "wrong_screen_usage", RegistryMutation::WrongScreenUsage }, + RejectionSpec{ "wrong_staging_size", RegistryMutation::WrongStagingSize }, + RejectionSpec{ "wrong_staging_usage", RegistryMutation::WrongStagingUsage }, + RejectionSpec{ "wrong_staging_memory", RegistryMutation::WrongStagingMemory }, + RejectionSpec{ "wrong_readback_size", RegistryMutation::WrongReadbackSize }, + RejectionSpec{ "wrong_readback_usage", RegistryMutation::WrongReadbackUsage }, + RejectionSpec{ "wrong_readback_memory", RegistryMutation::WrongReadbackMemory }, + RejectionSpec{ "alias_buffers", RegistryMutation::AliasBuffers }, + RejectionSpec{ "alias_buffer_memory", RegistryMutation::AliasBufferMemory }, + RejectionSpec{ "wrong_old_format", RegistryMutation::WrongOldFormat }, + RejectionSpec{ "wrong_old_extent", RegistryMutation::WrongOldExtent }, + RejectionSpec{ "wrong_old_mips", RegistryMutation::WrongOldMips }, + RejectionSpec{ "wrong_old_usage", RegistryMutation::WrongOldUsage }, + RejectionSpec{ "wrong_old_layout", RegistryMutation::WrongOldLayout }, + RejectionSpec{ "wrong_old_view_range", RegistryMutation::WrongOldViewRange }, + RejectionSpec{ "wrong_old_snapshot", RegistryMutation::WrongOldSnapshot }, + RejectionSpec{ "wrong_replacement_format", RegistryMutation::WrongReplacementFormat }, + RejectionSpec{ "wrong_replacement_extent", RegistryMutation::WrongReplacementExtent }, + RejectionSpec{ "wrong_replacement_mips", RegistryMutation::WrongReplacementMips }, + RejectionSpec{ "wrong_replacement_usage", RegistryMutation::WrongReplacementUsage }, + RejectionSpec{ "wrong_replacement_layout", RegistryMutation::WrongReplacementLayout }, + RejectionSpec{ "wrong_replacement_view_range", RegistryMutation::WrongReplacementViewRange }, + RejectionSpec{ "wrong_output_format", RegistryMutation::WrongOutputFormat }, + RejectionSpec{ "wrong_output_extent", RegistryMutation::WrongOutputExtent }, + RejectionSpec{ "wrong_output_mips", RegistryMutation::WrongOutputMips }, + RejectionSpec{ "wrong_output_usage", RegistryMutation::WrongOutputUsage }, + RejectionSpec{ "wrong_output_layout", RegistryMutation::WrongOutputLayout }, + RejectionSpec{ "wrong_output_view_range", RegistryMutation::WrongOutputViewRange }, + RejectionSpec{ "alias_images", RegistryMutation::AliasImages }, + RejectionSpec{ "alias_image_memory", RegistryMutation::AliasImageMemory }, + RejectionSpec{ "alias_views", RegistryMutation::AliasViews }, + RejectionSpec{ "wrong_sampler", RegistryMutation::WrongSampler }, + RejectionSpec{ "wrong_program", RegistryMutation::WrongProgram }, + RejectionSpec{ "wrong_variant", RegistryMutation::WrongVariant }, + RejectionSpec{ "wrong_pipeline_extent", RegistryMutation::WrongPipelineExtent }, + RejectionSpec{ "wrong_descriptor", RegistryMutation::WrongDescriptor }, + RejectionSpec{ "wrong_render_pass", RegistryMutation::WrongRenderPass }, + RejectionSpec{ "wrong_framebuffer", RegistryMutation::WrongFramebuffer }, + RejectionSpec{ "wrong_vertex_state", RegistryMutation::WrongVertexState }, + RejectionSpec{ "wrong_viewport_policy", RegistryMutation::WrongViewportPolicy }, + RejectionSpec{ "wrong_raster_state", RegistryMutation::WrongRasterState }, + RejectionSpec{ "wrong_shader_identity", RegistryMutation::WrongShaderIdentity }, + RejectionSpec{ "missing_lifecycle", RegistryMutation::MissingLifecycle }, + RejectionSpec{ "wrong_lifecycle_current", RegistryMutation::WrongLifecycleCurrent }, + RejectionSpec{ "wrong_lifecycle_revision", RegistryMutation::WrongLifecycleRevision }, + RejectionSpec{ "pending_lifecycle", RegistryMutation::PendingLifecycle }, + RejectionSpec{ "prior_completion", RegistryMutation::PriorCompletion }, + RejectionSpec{ "prior_retirement", RegistryMutation::PriorRetirement }, + RejectionSpec{ "mismatched_resource_ownership", RegistryMutation::WrongOwnershipToken }, + // The complete frozen Stage 14 neutral packet matrix, retaining its + // original case names so cross-backend coverage remains auditable. + RejectionSpec{ "frame", RegistryMutation::None, PacketMutation::WrongFrame }, + RejectionSpec{ "destination", RegistryMutation::None, PacketMutation::WrongUploadDestinationGeneration }, + RejectionSpec{ "old_generation", RegistryMutation::None, PacketMutation::WrongOldPacketGeneration }, + RejectionSpec{ "output_generation", RegistryMutation::None, PacketMutation::WrongOutputPacketGeneration }, + RejectionSpec{ "image_alias", RegistryMutation::None, PacketMutation::PacketImageAlias }, + RejectionSpec{ "old_mip_count", RegistryMutation::None, PacketMutation::WrongOldPacketMipCount }, + RejectionSpec{ "replacement_mip_count", RegistryMutation::None, + PacketMutation::WrongReplacementPacketMipCount }, + RejectionSpec{ "output_mip_count", RegistryMutation::None, PacketMutation::WrongOutputPacketMipCount }, + RejectionSpec{ "revision", RegistryMutation::None, PacketMutation::DuplicateRevision }, + RejectionSpec{ "subresource", RegistryMutation::None, PacketMutation::WrongSubresource }, + RejectionSpec{ "offset", RegistryMutation::None, PacketMutation::WrongOffset }, + RejectionSpec{ "extent", RegistryMutation::None, PacketMutation::WrongResidentExtent }, + RejectionSpec{ "logical_extent", RegistryMutation::None, PacketMutation::WrongLogicalExtent }, + RejectionSpec{ "discard", RegistryMutation::None, PacketMutation::WrongResidentDiscard }, + RejectionSpec{ "format", RegistryMutation::None, PacketMutation::WrongSourceFormat }, + RejectionSpec{ "row_pitch", RegistryMutation::None, PacketMutation::WrongRowPitch }, + RejectionSpec{ "row_origin", RegistryMutation::None, PacketMutation::WrongRowOrigin }, + RejectionSpec{ "mip_policy", RegistryMutation::None, PacketMutation::WrongMipGeneration }, + RejectionSpec{ "pixel_offset", RegistryMutation::None, PacketMutation::WrongPixelOffset }, + RejectionSpec{ "pixel_size", RegistryMutation::None, PacketMutation::WrongPixelSize }, + RejectionSpec{ "before_state", RegistryMutation::None, PacketMutation::WrongBefore }, + RejectionSpec{ "during_state", RegistryMutation::None, PacketMutation::WrongDuring }, + RejectionSpec{ "after_state", RegistryMutation::None, PacketMutation::WrongAfter }, + RejectionSpec{ "sampler", RegistryMutation::None, PacketMutation::WrongSamplerPacket }, + RejectionSpec{ "missing_sampler", RegistryMutation::None, PacketMutation::MissingSamplerPacket }, + RejectionSpec{ "pipeline", RegistryMutation::None, PacketMutation::WrongPipelinePacket }, + RejectionSpec{ "missing_pipeline", RegistryMutation::None, PacketMutation::MissingPipelinePacket }, + RejectionSpec{ "output", RegistryMutation::None, PacketMutation::WrongOutputPacketExtent }, + RejectionSpec{ "release", RegistryMutation::None, PacketMutation::WrongReleaseFrame }, + RejectionSpec{ "released_resource", RegistryMutation::None, PacketMutation::WrongRelease }, + RejectionSpec{ "missing_release", RegistryMutation::None, PacketMutation::MissingRelease }, + RejectionSpec{ "extra_pass", RegistryMutation::None, PacketMutation::ExtraPass }, + + // A small decoder-shape supplement covers storage ownership and both + // missing/duplicate command containers without duplicating every field + // already frozen by the neutral decoder tests. + RejectionSpec{ "pixel_storage_size", RegistryMutation::None, PacketMutation::WrongPixelStorageSize }, + RejectionSpec{ "missing_pass", RegistryMutation::None, PacketMutation::MissingPass }, + RejectionSpec{ "viewport", RegistryMutation::None, PacketMutation::WrongViewport }, + RejectionSpec{ "missing_draw", RegistryMutation::None, PacketMutation::MissingDraw }, + RejectionSpec{ "extra_draw", RegistryMutation::None, PacketMutation::ExtraDraw }, + RejectionSpec{ "sampled_range", RegistryMutation::None, PacketMutation::WrongSampleRange }, + RejectionSpec{ "null_device", RegistryMutation::None, PacketMutation::None, ContextMutation::NullDevice }, + RejectionSpec{ "null_command_buffer", RegistryMutation::None, PacketMutation::None, + ContextMutation::NullCommandBuffer }, + RejectionSpec{ "null_queue", RegistryMutation::None, PacketMutation::None, ContextMutation::NullQueue }, + RejectionSpec{ "missing_recording_counter", RegistryMutation::None, PacketMutation::None, + ContextMutation::MissingRecordingCounter }, + RejectionSpec{ "missing_submission_counter", RegistryMutation::None, PacketMutation::None, + ContextMutation::MissingSubmissionCounter }, + RejectionSpec{ "wrong_required_vertex_identity", RegistryMutation::None, PacketMutation::None, + ContextMutation::WrongVertexIdentity }, + RejectionSpec{ "wrong_required_fragment_identity", RegistryMutation::None, PacketMutation::None, + ContextMutation::WrongFragmentIdentity }, + RejectionSpec{ "null_command_pool", RegistryMutation::None, PacketMutation::None, + ContextMutation::NullCommandPool }, + RejectionSpec{ "wrong_context_ownership", RegistryMutation::None, PacketMutation::None, + ContextMutation::WrongOwnershipToken }, + RejectionSpec{ "ignored_queue_family", RegistryMutation::None, PacketMutation::None, + ContextMutation::IgnoredQueueFamily }, + RejectionSpec{ "missing_graphics_queue_flag", RegistryMutation::None, PacketMutation::None, + ContextMutation::MissingGraphicsQueueFlag }, + RejectionSpec{ "missing_transfer_queue_flag", RegistryMutation::None, PacketMutation::None, + ContextMutation::MissingTransferQueueFlag }, + RejectionSpec{ "wrong_queue_count", RegistryMutation::None, PacketMutation::None, + ContextMutation::WrongQueueCount }, + RejectionSpec{ "wrong_queue_index", RegistryMutation::None, PacketMutation::None, + ContextMutation::WrongQueueIndex }, + RejectionSpec{ "wrong_command_pool_family", RegistryMutation::None, PacketMutation::None, + ContextMutation::WrongCommandPoolFamily }, + RejectionSpec{ "missing_command_pool_reset", RegistryMutation::None, PacketMutation::None, + ContextMutation::MissingCommandPoolReset }, + RejectionSpec{ "wrong_command_buffer_level", RegistryMutation::None, PacketMutation::None, + ContextMutation::WrongCommandBufferLevel } + }; + + const auto resolution = [&diagnostic_case](const LLRenderVulkanTextureUpload::Registry& registry) + { + const auto& handles = diagnostic_case.mInputs.mHandles; + return std::array{ + registry.resolve(handles.mScreenTriangle) != nullptr, + registry.resolveRegisteredImage(handles.mOldImage) != nullptr, + registry.resolveRegisteredImage(handles.mReplacementImage) != nullptr, + registry.resolveOutput(handles.mOutput) != nullptr, + registry.resolve(handles.mSampler) != nullptr, + registry.resolve(handles.mPipeline, diagnostic_case.mFrame.mPipelines.front().mProgram) != nullptr, + registry.transferResources() != nullptr, + registry.lifecycle() != nullptr, + registry.isResolvable(handles.mOldImage), + registry.isResolvable(handles.mReplacementImage) + }; + }; + + std::array staging_poison{}; + std::array readback_poison{}; + for (std::size_t index = 0; index < staging_poison.size(); ++index) + { + staging_poison[index] = static_cast((index * 43U + 17U) & 0xffU); + } + for (std::size_t index = 0; index < readback_poison.size(); ++index) + { + readback_poison[index] = static_cast((index * 29U + 91U) & 0xffU); + } + + for (const RejectionSpec& rejection : REJECTIONS) + { + const std::vector old_before = snapshotImage(mOld); + const std::vector replacement_before = snapshotImage(mReplacement); + const std::vector output_before = snapshotImage(mOutputImage); + if (!std::equal(old_before.begin(), old_before.end(), fixture.mOldMipRGBA8.begin()) || + !std::equal(replacement_before.begin(), replacement_before.end(), + fixture.mReplacementSentinelMipRGBA8.begin()) || + !std::equal(output_before.begin(), output_before.end(), fixture.mOutputSentinelRGBA8.begin())) + { + throw Failure("a rejection sentinel was not canonical before executor preflight"); + } + writeBuffer(mStaging, staging_poison.data(), staging_poison.size()); + writeBuffer(mReadback, readback_poison.data(), readback_poison.size()); + + StreamingUploadLifecycle lifecycle; + lifecycle.mCurrentImage = diagnostic_case.mInputs.mHandles.mOldImage; + lifecycle.mLastRevision = LLRenderContract::TEXTURE_UPLOAD_PRIOR_REVISION; + LLRenderVulkanTextureUpload::Registry registry = makeRegistry( + diagnostic_case, rejection.mRegistry, &lifecycle); + const auto resolution_before = resolution(registry); + const StreamingUploadLifecycle lifecycle_before = lifecycle; + const TextureUploadArtifact result_before = LLRenderContract::makeTextureUploadArtifact(); + TextureUploadArtifact result = result_before; + const FrameSnapshot frame = mutateFrame(diagnostic_case, rejection.mPacket); + const std::uint64_t recordings_before = mExecutorRecordings; + const std::uint64_t submissions_before = mExecutorSubmissions; + const std::uint32_t validation_before = validationMessageCount(); + std::string execution_error; + const bool executed = LLRenderVulkanTextureUpload::execute( + frame, registry, executionContext(rejection.mContext), result, &execution_error); + + const std::vector staging_after = readBuffer(mStaging, staging_poison.size()); + const std::vector readback_after = readBuffer(mReadback, readback_poison.size()); + const auto resolution_after = resolution(registry); + if (executed || execution_error.empty() || mExecutorRecordings != recordings_before || + mExecutorSubmissions != submissions_before || validationMessageCount() != validation_before || + result != result_before || lifecycle != lifecycle_before || resolution_after != resolution_before || + !std::equal(staging_after.begin(), staging_after.end(), staging_poison.begin()) || + !std::equal(readback_after.begin(), readback_after.end(), readback_poison.begin())) + { + throw Failure(std::string("fail-closed rejection failed before image snapshot: ") + rejection.mName); + } + const std::vector old_after = snapshotImage(mOld); + const std::vector replacement_after = snapshotImage(mReplacement); + const std::vector output_after = snapshotImage(mOutputImage); + if (old_after != old_before || replacement_after != replacement_before || output_after != output_before) + { + throw Failure(std::string("fail-closed rejection mutated an image: ") + rejection.mName); + } + if (validationMessageCount() != validation_before) + { + throw Failure(std::string("fail-closed rejection produced a validation message: ") + rejection.mName); + } + ++mRejectionCount; + } +} + +TextureUploadArtifact VulkanTextureUploadRun::run() +{ + readShaders(); + createInstance(); + selectPhysicalDevice(); + createDevice(); + createCommandResources(); + const TextureUploadFixture fixture = LLRenderContract::makeTextureUploadFixture(); + const TextureUploadCase diagnostic_case = LLRenderContract::makeTextureUploadCase(); + createResources(fixture); + createDescriptors(); + createRenderPass(); + createPipeline(); + seedImages(fixture); + const std::vector old_snapshot = snapshotImage(mOld); + if (old_snapshot.size() != mOldPreExecutionSnapshot.size() || + !std::equal(old_snapshot.begin(), old_snapshot.end(), fixture.mOldMipRGBA8.begin())) + { + throw Failure("old-image pre-execution snapshot does not match the fixed sentinel"); + } + std::copy(old_snapshot.begin(), old_snapshot.end(), mOldPreExecutionSnapshot.begin()); + runRejections(diagnostic_case, fixture); + + if (mExecutorRecordings != 0 || mExecutorSubmissions != 0) + { + throw Failure("rejection matrix did not remain before executor recording"); + } + const std::vector old_before = snapshotImage(mOld); + StreamingUploadLifecycle lifecycle; + lifecycle.mCurrentImage = diagnostic_case.mInputs.mHandles.mOldImage; + lifecycle.mLastRevision = LLRenderContract::TEXTURE_UPLOAD_PRIOR_REVISION; + LLRenderVulkanTextureUpload::Registry registry = makeRegistry( + diagnostic_case, RegistryMutation::None, &lifecycle); + TextureUploadArtifact result = LLRenderContract::makeTextureUploadArtifact(); + std::string execution_error; + if (!LLRenderVulkanTextureUpload::execute(diagnostic_case.mFrame, registry, + executionContext(), result, &execution_error)) + { + throw Failure("valid Vulkan texture upload failed: " + execution_error); + } + if (mExecutorRecordings != 1 || mExecutorSubmissions != 1) + { + throw Failure("valid Vulkan texture upload did not record and submit exactly once"); + } + const std::vector old_after = snapshotImage(mOld); + if (old_after != old_before || + !std::equal(old_after.begin(), old_after.end(), fixture.mOldMipRGBA8.begin())) + { + throw Failure("valid Vulkan upload mutated the retired old-image sentinel"); + } + if (lifecycle.mCurrentImage != diagnostic_case.mInputs.mHandles.mReplacementImage || + lifecycle.mLastRevision != LLRenderContract::TEXTURE_UPLOAD_REVISION || + lifecycle.mCompletionPending || lifecycle.mCompletionCount != 1 || + lifecycle.mCompletedDestination != diagnostic_case.mInputs.mHandles.mReplacementImage || + lifecycle.mCompletedRevision != LLRenderContract::TEXTURE_UPLOAD_REVISION || + lifecycle.mCompletedFrame != LLRenderContract::TEXTURE_UPLOAD_DIAGNOSTIC_FRAME || + lifecycle.mRetirementCount != 1 || + lifecycle.mRetiredResource != diagnostic_case.mInputs.mHandles.mOldImage || + lifecycle.mRetirementFrame != LLRenderContract::TEXTURE_UPLOAD_DIAGNOSTIC_FRAME || + registry.isResolvable(diagnostic_case.mInputs.mHandles.mOldImage) || + !registry.isResolvable(diagnostic_case.mInputs.mHandles.mReplacementImage)) + { + throw Failure("valid Vulkan upload did not publish the exact lifecycle transition"); + } + std::string artifact_error; + if (!LLRenderContract::validateTextureUploadArtifact(result, &artifact_error)) + { + throw Failure("Vulkan texture-upload artifact is invalid: " + artifact_error); + } + return result; +} + +void VulkanTextureUploadRun::shutdown() noexcept +{ + if (mDevice != VK_NULL_HANDLE) + { + vkDeviceWaitIdle(mDevice); + if (mPipeline != VK_NULL_HANDLE) vkDestroyPipeline(mDevice, mPipeline, nullptr); + if (mFramebuffer != VK_NULL_HANDLE) vkDestroyFramebuffer(mDevice, mFramebuffer, nullptr); + if (mRenderPass != VK_NULL_HANDLE) vkDestroyRenderPass(mDevice, mRenderPass, nullptr); + if (mPipelineLayout != VK_NULL_HANDLE) vkDestroyPipelineLayout(mDevice, mPipelineLayout, nullptr); + if (mDescriptorPool != VK_NULL_HANDLE) vkDestroyDescriptorPool(mDevice, mDescriptorPool, nullptr); + if (mDescriptorSetLayout != VK_NULL_HANDLE) vkDestroyDescriptorSetLayout(mDevice, mDescriptorSetLayout, nullptr); + if (mSampler != VK_NULL_HANDLE) vkDestroySampler(mDevice, mSampler, nullptr); + destroyImage(mOutputImage); + destroyImage(mReplacement); + destroyImage(mOld); + destroyBuffer(mReadback); + destroyBuffer(mStaging); + destroyBuffer(mScreen); + if (mCommandPool != VK_NULL_HANDLE) vkDestroyCommandPool(mDevice, mCommandPool, nullptr); + vkDestroyDevice(mDevice, nullptr); + mDevice = VK_NULL_HANDLE; + mQueue = VK_NULL_HANDLE; + mCommandPool = VK_NULL_HANDLE; + mCommandBuffer = VK_NULL_HANDLE; + } + if (mDebugMessenger != VK_NULL_HANDLE && mInstance != VK_NULL_HANDLE) + { + const auto destroy_debug = reinterpret_cast( + vkGetInstanceProcAddr(mInstance, "vkDestroyDebugUtilsMessengerEXT")); + if (destroy_debug) destroy_debug(mInstance, mDebugMessenger, nullptr); + mDebugMessenger = VK_NULL_HANDLE; + } + if (mInstance != VK_NULL_HANDLE) + { + vkDestroyInstance(mInstance, nullptr); + mInstance = VK_NULL_HANDLE; + } +} + +std::string boundedDetail(std::string detail) +{ + for (char& character : detail) + { + if (character == '\n' || character == '\r' || character == '{' || character == '}') + { + character = ' '; + } + } + constexpr std::size_t LIMIT = 240; + if (detail.size() > LIMIT) + { + detail.resize(LIMIT); + } + return detail; +} + +int fail(const std::string& reason, const std::string& detail) +{ + std::cerr << "VULKAN_TEXTURE_UPLOAD result=fail reason=" << reason; + if (!detail.empty()) + { + std::cerr << " detail={" << boundedDetail(detail) << '}'; + } + std::cerr << '\n'; + return 1; +} + +} // namespace + +int main(int argc, char** argv) +{ + Options options; + std::string option_error; + if (!parseOptions(argc, argv, options, option_error)) + { + fail("usage", option_error); + std::cerr << "usage: llvulkantextureupload --shader-dir --output \n"; + return 2; + } + try + { + std::string path_error; + if (occupiedPath(options.mOutput, path_error)) + { + throw Failure(path_error.empty() ? "output artifact already exists: " + options.mOutput.string() + : path_error); + } + VulkanTextureUploadRun runner(options.mShaderDirectory); + TextureUploadArtifact artifact = runner.run(); + runner.shutdown(); + if (runner.validationMessageCount() != 0) + { + throw Failure(std::to_string(runner.validationMessageCount()) + " validation messages"); + } + std::string artifact_error; + if (!LLRenderContract::writeTextureUploadArtifact(options.mOutput, artifact, &artifact_error)) + { + throw Failure(artifact_error); + } + if (!artifact_error.empty()) + { + std::cerr << "VULKAN_TEXTURE_UPLOAD publication_warning=cleanup\n"; + } + std::cout << "VULKAN_TEXTURE_UPLOAD result=pass" + << " rejection_cases=" << runner.rejectionCount() + << " recording_attempts=" << runner.recordingAttemptCount() + << " submissions=" << runner.submissionCount() + << " mip_bytes=" << LLRenderContract::TEXTURE_UPLOAD_MIP_BYTE_COUNT + << " sample_bytes=" << LLRenderContract::TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT + << " mismatches=0" + << " completions=" << artifact.mCompletionCount + << " retirements=" << artifact.mRetirementCount + << " validation_messages=" << runner.validationMessageCount() + << " portability_enumeration=" + << (runner.usedPortabilityEnumeration() ? "enabled" : "not_advertised") + << " portability_subset=" + << (runner.usedPortabilitySubset() ? "enabled" : "not_advertised") + << " artifact=written\n"; + return 0; + } + catch (const CapabilityFailure& exception) + { + return fail("capability", exception.what()); + } + catch (const std::exception& exception) + { + return fail("execution", exception.what()); + } +} diff --git a/indra/llrender/vulkan/llvulkantonemap_main.cpp b/indra/llrender/vulkan/llvulkantonemap_main.cpp new file mode 100644 index 00000000000..fe64c5cb903 --- /dev/null +++ b/indra/llrender/vulkan/llvulkantonemap_main.cpp @@ -0,0 +1,1756 @@ +/** + * @file llvulkantonemap_main.cpp + * @brief Offscreen Vulkan replay for the fixed-input tonemap diagnostic. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llrendervulkantonemap.h" +#include "lltonemapdiagnostic.h" + +#include + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace +{ + +using LLRenderContract::AddressMode; +using LLRenderContract::Extent2D; +using LLRenderContract::Filter; +using LLRenderContract::PixelFormat; +using LLRenderContract::TonemapArtifact; +using LLRenderContract::TonemapCase; +using LLRenderContract::TonemapCases; +using LLRenderContract::TonemapFixture; +using LLRenderContract::TonemapVariant; + +constexpr char PORTABILITY_ENUMERATION_EXTENSION[] = "VK_KHR_portability_enumeration"; +constexpr char PORTABILITY_SUBSET_EXTENSION[] = "VK_KHR_portability_subset"; +constexpr VkDeviceSize SCENE_BYTES = + LLRenderContract::TONEMAP_DIAGNOSTIC_COMPONENT_COUNT * sizeof(std::uint16_t); +constexpr VkDeviceSize EXPOSURE_OFFSET = SCENE_BYTES; +constexpr VkDeviceSize FIXTURE_STAGING_BYTES = EXPOSURE_OFFSET + 4; +constexpr VkDeviceSize RGBA8_BYTES = LLRenderContract::TONEMAP_DIAGNOSTIC_COMPONENT_COUNT; +constexpr VkDeviceSize RGBA16F_BYTES = + LLRenderContract::TONEMAP_DIAGNOSTIC_COMPONENT_COUNT * sizeof(std::uint16_t); + +constexpr std::array TONEMAP_VARIANTS{ + TonemapVariant::Deferred, + TonemapVariant::NoPost, + TonemapVariant::GammaCorrect, + TonemapVariant::NoPostGammaCorrect, + TonemapVariant::LegacyGammaCorrect, + TonemapVariant::NoPostLegacyGammaCorrect +}; + +class Failure : public std::runtime_error +{ +public: + using std::runtime_error::runtime_error; +}; + +class CapabilityFailure : public Failure +{ +public: + using Failure::Failure; +}; + +void check(VkResult result, const char* operation) +{ + if (result != VK_SUCCESS) + { + std::ostringstream message; + message << operation << " failed with VkResult " << result; + throw Failure(message.str()); + } +} + +template +std::vector enumerate(Function&& function, const char* operation) +{ + for (;;) + { + std::uint32_t count = 0; + check(function(&count, nullptr), operation); + std::vector values(count); + VkResult result = function(&count, values.data()); + if (result == VK_INCOMPLETE) + { + continue; + } + check(result, operation); + values.resize(count); + return values; + } +} + +template +bool hasName(const std::vector& properties, const char* expected) +{ + return std::any_of(properties.begin(), properties.end(), [expected](const Property& property) + { + if constexpr (std::is_same_v) + { + return std::strcmp(property.layerName, expected) == 0; + } + else + { + return std::strcmp(property.extensionName, expected) == 0; + } + }); +} + +struct Options +{ + std::filesystem::path mShaderDirectory; + std::filesystem::path mOutput; +}; + +bool parseOptions(int argc, char** argv, Options& options, std::string& error) +{ + bool have_shader_directory = false; + bool have_output = false; + for (int index = 1; index < argc; ++index) + { + const std::string argument = argv[index]; + if (argument != "--shader-dir" && argument != "--output") + { + error = "unknown argument: " + argument; + return false; + } + if (index + 1 >= argc) + { + error = argument + " requires a path"; + return false; + } + + const std::filesystem::path value = argv[++index]; + if (value.empty()) + { + error = argument + " requires a non-empty path"; + return false; + } + if (argument == "--shader-dir") + { + if (have_shader_directory) + { + error = "--shader-dir was specified more than once"; + return false; + } + options.mShaderDirectory = value; + have_shader_directory = true; + } + else + { + if (have_output) + { + error = "--output was specified more than once"; + return false; + } + options.mOutput = value; + have_output = true; + } + } + + if (!have_shader_directory || !have_output) + { + error = "both --shader-dir and --output are required"; + return false; + } + return true; +} + +struct ValidationState +{ + std::atomic mMessages{ 0 }; + std::mutex mMutex; + std::string mFirstMessage; +}; + +VKAPI_ATTR VkBool32 VKAPI_CALL validationCallback( + VkDebugUtilsMessageSeverityFlagBitsEXT, + VkDebugUtilsMessageTypeFlagsEXT, + const VkDebugUtilsMessengerCallbackDataEXT* callback_data, + void* user_data) +{ + auto& state = *static_cast(user_data); + ++state.mMessages; + std::lock_guard lock(state.mMutex); + if (state.mFirstMessage.empty() && callback_data && callback_data->pMessage) + { + state.mFirstMessage = callback_data->pMessage; + } + return VK_FALSE; +} + +struct Buffer +{ + VkBuffer mBuffer = VK_NULL_HANDLE; + VkDeviceMemory mMemory = VK_NULL_HANDLE; + VkDeviceSize mSize = 0; + VkDeviceSize mAllocationSize = 0; + bool mCoherent = false; +}; + +struct Image +{ + VkImage mImage = VK_NULL_HANDLE; + VkDeviceMemory mMemory = VK_NULL_HANDLE; + VkImageView mView = VK_NULL_HANDLE; + VkFormat mFormat = VK_FORMAT_UNDEFINED; + Extent2D mExtent; + VkImageUsageFlags mUsage = 0; +}; + +struct Destination +{ + PixelFormat mContractFormat = PixelFormat::RGBA8Unorm; + Image mImage; + VkRenderPass mRenderPass = VK_NULL_HANDLE; + VkFramebuffer mFramebuffer = VK_NULL_HANDLE; + std::array mPipelines{}; +}; + +enum class RegistryMutation +{ + None, + StaleGeneration, + WrongProgram, + WrongVariant, + WrongExtent, + WrongFormat, + WrongSampler, + WrongDescriptors, + WrongParameterSize +}; + +const char* mutationName(RegistryMutation mutation) +{ + switch (mutation) + { + case RegistryMutation::None: return "none"; + case RegistryMutation::StaleGeneration: return "stale_generation"; + case RegistryMutation::WrongProgram: return "wrong_program"; + case RegistryMutation::WrongVariant: return "wrong_variant"; + case RegistryMutation::WrongExtent: return "wrong_extent"; + case RegistryMutation::WrongFormat: return "wrong_format"; + case RegistryMutation::WrongSampler: return "wrong_sampler"; + case RegistryMutation::WrongDescriptors: return "wrong_descriptors"; + case RegistryMutation::WrongParameterSize: return "wrong_parameter_size"; + } + return "unknown"; +} + +std::size_t variantOffset(TonemapVariant variant) +{ + const auto found = std::find(TONEMAP_VARIANTS.begin(), TONEMAP_VARIANTS.end(), variant); + if (found == TONEMAP_VARIANTS.end()) + { + throw Failure("tonemap case contains an unsupported variant"); + } + return static_cast(found - TONEMAP_VARIANTS.begin()); +} + +VkFormat vkFormat(PixelFormat format) +{ + switch (format) + { + case PixelFormat::RGBA8Unorm: return VK_FORMAT_R8G8B8A8_UNORM; + case PixelFormat::RGBA16Float: return VK_FORMAT_R16G16B16A16_SFLOAT; + default: throw Failure("tonemap case contains an unsupported destination format"); + } +} + +VkDeviceSize outputByteCount(PixelFormat format) +{ + return format == PixelFormat::RGBA8Unorm ? RGBA8_BYTES : RGBA16F_BYTES; +} + +class VulkanTonemapRun +{ +public: + explicit VulkanTonemapRun(std::filesystem::path shader_directory) + : mShaderDirectory(std::move(shader_directory)) + { + } + + ~VulkanTonemapRun() + { + shutdown(); + } + + TonemapArtifact run() + { + verifyShaderFiles(); + createInstance(); + selectPhysicalDevice(); + createDevice(); + createCommandResources(); + + const TonemapFixture fixture = LLRenderContract::makeTonemapFixture(); + createFixtureResources(fixture); + createDescriptorResources(); + createDestinations(); + createPipelines(); + + const TonemapCases cases = LLRenderContract::makeTonemapCases(); + runPreflight(cases.front()); + + TonemapArtifact artifact = LLRenderContract::makeTonemapArtifact(); + for (std::size_t offset = 0; offset < cases.size(); ++offset) + { + const TonemapCase& diagnostic_case = cases[offset]; + Destination& output = destination(diagnostic_case.mKey.mDestinationFormat); + LLRenderVulkanTonemap::Registry registry = makeRegistry(diagnostic_case, output, RegistryMutation::None); + std::string execution_error; + if (!LLRenderVulkanTonemap::execute(diagnostic_case.mFrame, registry, executionContext(), execution_error)) + { + std::ostringstream message; + message << "case " << diagnostic_case.mKey.mIndex << " execution failed: " << execution_error; + throw Failure(message.str()); + } + artifact.mCases[offset].mPixels = readPixels(output, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL); + } + + if (mExecutorSubmissions != cases.size()) + { + throw Failure("executor submission count does not equal the canonical case count"); + } + std::string artifact_error; + if (!LLRenderContract::validateTonemapArtifact(artifact, &artifact_error)) + { + throw Failure("Vulkan readback is not a canonical artifact: " + artifact_error); + } + return artifact; + } + + void shutdown() noexcept + { + if (mDevice != VK_NULL_HANDLE) + { + vkDeviceWaitIdle(mDevice); + for (Destination& output : mDestinations) + { + for (VkPipeline pipeline : output.mPipelines) + { + if (pipeline != VK_NULL_HANDLE) + { + vkDestroyPipeline(mDevice, pipeline, nullptr); + } + } + if (output.mFramebuffer != VK_NULL_HANDLE) + { + vkDestroyFramebuffer(mDevice, output.mFramebuffer, nullptr); + } + if (output.mRenderPass != VK_NULL_HANDLE) + { + vkDestroyRenderPass(mDevice, output.mRenderPass, nullptr); + } + destroyImage(output.mImage); + } + mDestinations.clear(); + + if (mPipelineLayout != VK_NULL_HANDLE) + { + vkDestroyPipelineLayout(mDevice, mPipelineLayout, nullptr); + mPipelineLayout = VK_NULL_HANDLE; + } + if (mDescriptorPool != VK_NULL_HANDLE) + { + vkDestroyDescriptorPool(mDevice, mDescriptorPool, nullptr); + mDescriptorPool = VK_NULL_HANDLE; + mDescriptorSet = VK_NULL_HANDLE; + } + if (mDescriptorSetLayout != VK_NULL_HANDLE) + { + vkDestroyDescriptorSetLayout(mDevice, mDescriptorSetLayout, nullptr); + mDescriptorSetLayout = VK_NULL_HANDLE; + } + if (mPointSampler != VK_NULL_HANDLE) + { + vkDestroySampler(mDevice, mPointSampler, nullptr); + mPointSampler = VK_NULL_HANDLE; + } + if (mLinearSampler != VK_NULL_HANDLE) + { + vkDestroySampler(mDevice, mLinearSampler, nullptr); + mLinearSampler = VK_NULL_HANDLE; + } + destroyImage(mScene); + destroyImage(mExposure); + destroyBuffer(mScreenTriangle); + destroyBuffer(mTransferBuffer); + if (mCommandPool != VK_NULL_HANDLE) + { + vkDestroyCommandPool(mDevice, mCommandPool, nullptr); + mCommandPool = VK_NULL_HANDLE; + mCommandBuffer = VK_NULL_HANDLE; + } + vkDestroyDevice(mDevice, nullptr); + mDevice = VK_NULL_HANDLE; + mQueue = VK_NULL_HANDLE; + } + + if (mDebugMessenger != VK_NULL_HANDLE && mInstance != VK_NULL_HANDLE) + { + const auto destroy_debug = reinterpret_cast( + vkGetInstanceProcAddr(mInstance, "vkDestroyDebugUtilsMessengerEXT")); + if (destroy_debug) + { + destroy_debug(mInstance, mDebugMessenger, nullptr); + } + mDebugMessenger = VK_NULL_HANDLE; + } + if (mInstance != VK_NULL_HANDLE) + { + vkDestroyInstance(mInstance, nullptr); + mInstance = VK_NULL_HANDLE; + } + } + + std::uint32_t validationMessageCount() const noexcept + { + return mValidation.mMessages.load(); + } + + std::string firstValidationMessage() + { + std::lock_guard lock(mValidation.mMutex); + return mValidation.mFirstMessage; + } + + std::uint64_t executorSubmissionCount() const noexcept { return mExecutorSubmissions; } + std::size_t preflightCount() const noexcept { return mPreflightCount; } + bool usedPortabilityEnumeration() const noexcept { return mPortabilityEnumeration; } + bool usedPortabilitySubset() const noexcept { return mPortabilitySubset; } + +private: + void verifyShaderFiles() const + { + std::error_code error; + if (!std::filesystem::is_directory(mShaderDirectory, error) || error) + { + throw Failure("shader directory is not readable: " + mShaderDirectory.string()); + } + std::vector paths{ mShaderDirectory / "tonemap.vert.spv" }; + for (TonemapVariant variant : TONEMAP_VARIANTS) + { + paths.push_back(mShaderDirectory / + ("tonemap.frag." + std::to_string(static_cast(variant)) + ".spv")); + } + for (const std::filesystem::path& path : paths) + { + const std::uintmax_t size = std::filesystem::file_size(path, error); + if (error || size == 0 || size % sizeof(std::uint32_t) != 0 || size > 16U * 1024U * 1024U) + { + throw Failure("SPIR-V file is missing or has an invalid size: " + path.string()); + } + } + } + + void createInstance() + { + const auto layers = enumerate( + [](std::uint32_t* count, VkLayerProperties* values) + { + return vkEnumerateInstanceLayerProperties(count, values); + }, + "vkEnumerateInstanceLayerProperties"); + if (!hasName(layers, "VK_LAYER_KHRONOS_validation")) + { + throw CapabilityFailure("VK_LAYER_KHRONOS_validation is required but unavailable"); + } + + const auto extensions = enumerate( + [](std::uint32_t* count, VkExtensionProperties* values) + { + return vkEnumerateInstanceExtensionProperties(nullptr, count, values); + }, + "vkEnumerateInstanceExtensionProperties"); + if (!hasName(extensions, VK_EXT_DEBUG_UTILS_EXTENSION_NAME)) + { + throw CapabilityFailure("VK_EXT_debug_utils is required but unavailable"); + } + + std::uint32_t loader_version = VK_API_VERSION_1_0; + const auto enumerate_version = reinterpret_cast( + vkGetInstanceProcAddr(VK_NULL_HANDLE, "vkEnumerateInstanceVersion")); + if (enumerate_version) + { + check(enumerate_version(&loader_version), "vkEnumerateInstanceVersion"); + } + if (loader_version < VK_API_VERSION_1_1) + { + throw CapabilityFailure("the Vulkan 1.1 loader required by the shader target is unavailable"); + } + + std::vector enabled_extensions{ VK_EXT_DEBUG_UTILS_EXTENSION_NAME }; + VkInstanceCreateFlags flags = 0; + if (hasName(extensions, PORTABILITY_ENUMERATION_EXTENSION)) + { + enabled_extensions.push_back(PORTABILITY_ENUMERATION_EXTENSION); + flags |= VK_INSTANCE_CREATE_ENUMERATE_PORTABILITY_BIT_KHR; + mPortabilityEnumeration = true; + } + + VkDebugUtilsMessengerCreateInfoEXT debug_info{ VK_STRUCTURE_TYPE_DEBUG_UTILS_MESSENGER_CREATE_INFO_EXT }; + debug_info.messageSeverity = VK_DEBUG_UTILS_MESSAGE_SEVERITY_WARNING_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_SEVERITY_ERROR_BIT_EXT; + debug_info.messageType = VK_DEBUG_UTILS_MESSAGE_TYPE_GENERAL_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_TYPE_VALIDATION_BIT_EXT | + VK_DEBUG_UTILS_MESSAGE_TYPE_PERFORMANCE_BIT_EXT; + debug_info.pfnUserCallback = validationCallback; + debug_info.pUserData = &mValidation; + + VkApplicationInfo application{ VK_STRUCTURE_TYPE_APPLICATION_INFO }; + application.pApplicationName = "llvulkantonemap"; + application.applicationVersion = 1; + application.pEngineName = "Second Life tonemap diagnostic"; + application.engineVersion = 1; + application.apiVersion = VK_API_VERSION_1_1; + + const char* validation_layer = "VK_LAYER_KHRONOS_validation"; + VkInstanceCreateInfo create_info{ VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO }; + create_info.pNext = &debug_info; + create_info.flags = flags; + create_info.pApplicationInfo = &application; + create_info.enabledLayerCount = 1; + create_info.ppEnabledLayerNames = &validation_layer; + create_info.enabledExtensionCount = static_cast(enabled_extensions.size()); + create_info.ppEnabledExtensionNames = enabled_extensions.data(); + check(vkCreateInstance(&create_info, nullptr, &mInstance), "vkCreateInstance"); + + const auto create_debug = reinterpret_cast( + vkGetInstanceProcAddr(mInstance, "vkCreateDebugUtilsMessengerEXT")); + if (!create_debug) + { + throw Failure("vkCreateDebugUtilsMessengerEXT is unavailable after enabling VK_EXT_debug_utils"); + } + check(create_debug(mInstance, &debug_info, nullptr, &mDebugMessenger), + "vkCreateDebugUtilsMessengerEXT"); + } + + bool hasRequiredFormats(VkPhysicalDevice physical_device) const + { + struct FormatRequirement + { + VkFormat mFormat; + VkFormatFeatureFlags mFeatures; + bool mBufferFeatures = false; + }; + constexpr std::array requirements{ + FormatRequirement{ VK_FORMAT_R16G16B16A16_SFLOAT, + VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | + VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | + VK_FORMAT_FEATURE_TRANSFER_SRC_BIT | + VK_FORMAT_FEATURE_TRANSFER_DST_BIT }, + FormatRequirement{ VK_FORMAT_R16_SFLOAT, + VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | + VK_FORMAT_FEATURE_SAMPLED_IMAGE_FILTER_LINEAR_BIT | + VK_FORMAT_FEATURE_TRANSFER_DST_BIT }, + FormatRequirement{ VK_FORMAT_R8G8B8A8_UNORM, + VK_FORMAT_FEATURE_SAMPLED_IMAGE_BIT | + VK_FORMAT_FEATURE_COLOR_ATTACHMENT_BIT | + VK_FORMAT_FEATURE_TRANSFER_SRC_BIT | + VK_FORMAT_FEATURE_TRANSFER_DST_BIT }, + FormatRequirement{ VK_FORMAT_R32G32B32_SFLOAT, + VK_FORMAT_FEATURE_VERTEX_BUFFER_BIT, + true } + }; + for (const FormatRequirement& requirement : requirements) + { + VkFormatProperties properties{}; + vkGetPhysicalDeviceFormatProperties(physical_device, requirement.mFormat, &properties); + const VkFormatFeatureFlags available = requirement.mBufferFeatures + ? properties.bufferFeatures + : properties.optimalTilingFeatures; + if ((available & requirement.mFeatures) != requirement.mFeatures) + { + return false; + } + } + + struct ImageRequirement + { + VkFormat mFormat; + VkImageUsageFlags mUsage; + Extent2D mExtent; + }; + constexpr VkImageUsageFlags destination_usage = + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | VK_IMAGE_USAGE_SAMPLED_BIT | + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT; + constexpr std::array image_requirements{ + ImageRequirement{ VK_FORMAT_R16G16B16A16_SFLOAT, + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, + { LLRenderContract::TONEMAP_DIAGNOSTIC_WIDTH, + LLRenderContract::TONEMAP_DIAGNOSTIC_HEIGHT } }, + ImageRequirement{ VK_FORMAT_R16_SFLOAT, + VK_IMAGE_USAGE_SAMPLED_BIT | VK_IMAGE_USAGE_TRANSFER_DST_BIT, + { 1, 1 } }, + ImageRequirement{ VK_FORMAT_R8G8B8A8_UNORM, destination_usage, + { LLRenderContract::TONEMAP_DIAGNOSTIC_WIDTH, + LLRenderContract::TONEMAP_DIAGNOSTIC_HEIGHT } }, + ImageRequirement{ VK_FORMAT_R16G16B16A16_SFLOAT, destination_usage, + { LLRenderContract::TONEMAP_DIAGNOSTIC_WIDTH, + LLRenderContract::TONEMAP_DIAGNOSTIC_HEIGHT } } + }; + for (const ImageRequirement& requirement : image_requirements) + { + VkImageFormatProperties properties{}; + if (vkGetPhysicalDeviceImageFormatProperties( + physical_device, requirement.mFormat, VK_IMAGE_TYPE_2D, + VK_IMAGE_TILING_OPTIMAL, requirement.mUsage, 0, &properties) != VK_SUCCESS || + properties.maxExtent.width < requirement.mExtent.mWidth || + properties.maxExtent.height < requirement.mExtent.mHeight || + properties.maxMipLevels < 1 || properties.maxArrayLayers < 1 || + (properties.sampleCounts & VK_SAMPLE_COUNT_1_BIT) == 0) + { + return false; + } + } + return true; + } + + std::optional graphicsQueueFamily(VkPhysicalDevice physical_device) const + { + std::uint32_t count = 0; + vkGetPhysicalDeviceQueueFamilyProperties(physical_device, &count, nullptr); + std::vector properties(count); + vkGetPhysicalDeviceQueueFamilyProperties(physical_device, &count, properties.data()); + for (std::uint32_t index = 0; index < count; ++index) + { + if (properties[index].queueCount != 0 && + (properties[index].queueFlags & VK_QUEUE_GRAPHICS_BIT) != 0) + { + return index; + } + } + return std::nullopt; + } + + std::vector deviceExtensions(VkPhysicalDevice physical_device) const + { + return enumerate( + [physical_device](std::uint32_t* count, VkExtensionProperties* values) + { + return vkEnumerateDeviceExtensionProperties(physical_device, nullptr, count, values); + }, + "vkEnumerateDeviceExtensionProperties"); + } + + void selectPhysicalDevice() + { + const auto devices = enumerate( + [this](std::uint32_t* count, VkPhysicalDevice* values) + { + return vkEnumeratePhysicalDevices(mInstance, count, values); + }, + "vkEnumeratePhysicalDevices"); + for (VkPhysicalDevice device : devices) + { + VkPhysicalDeviceProperties properties{}; + vkGetPhysicalDeviceProperties(device, &properties); + const auto queue_family = graphicsQueueFamily(device); + if (properties.apiVersion < VK_API_VERSION_1_1 || + properties.limits.maxPushConstantsSize < sizeof(LLRenderContract::TonemapParameters) || + properties.limits.maxFramebufferWidth < LLRenderContract::TONEMAP_DIAGNOSTIC_WIDTH || + properties.limits.maxFramebufferHeight < LLRenderContract::TONEMAP_DIAGNOSTIC_HEIGHT || + !queue_family || !hasRequiredFormats(device)) + { + continue; + } + mPhysicalDevice = device; + mQueueFamily = *queue_family; + const auto extensions = deviceExtensions(device); + mPortabilitySubset = hasName(extensions, PORTABILITY_SUBSET_EXTENSION); + return; + } + throw CapabilityFailure("no Vulkan 1.1 graphics device supports the exact tonemap formats and features"); + } + + void createDevice() + { + const float priority = 1.f; + VkDeviceQueueCreateInfo queue_info{ VK_STRUCTURE_TYPE_DEVICE_QUEUE_CREATE_INFO }; + queue_info.queueFamilyIndex = mQueueFamily; + queue_info.queueCount = 1; + queue_info.pQueuePriorities = &priority; + + std::vector extensions; + if (mPortabilitySubset) + { + extensions.push_back(PORTABILITY_SUBSET_EXTENSION); + } + + VkDeviceCreateInfo create_info{ VK_STRUCTURE_TYPE_DEVICE_CREATE_INFO }; + create_info.queueCreateInfoCount = 1; + create_info.pQueueCreateInfos = &queue_info; + create_info.enabledExtensionCount = static_cast(extensions.size()); + create_info.ppEnabledExtensionNames = extensions.data(); + check(vkCreateDevice(mPhysicalDevice, &create_info, nullptr, &mDevice), "vkCreateDevice"); + vkGetDeviceQueue(mDevice, mQueueFamily, 0, &mQueue); + if (mQueue == VK_NULL_HANDLE) + { + throw Failure("vkGetDeviceQueue returned a null graphics queue"); + } + } + + std::uint32_t memoryType(std::uint32_t type_bits, + VkMemoryPropertyFlags required, + VkMemoryPropertyFlags preferred, + VkMemoryPropertyFlags& selected_properties) const + { + VkPhysicalDeviceMemoryProperties properties{}; + vkGetPhysicalDeviceMemoryProperties(mPhysicalDevice, &properties); + for (int pass = 0; pass < 2; ++pass) + { + for (std::uint32_t index = 0; index < properties.memoryTypeCount; ++index) + { + const VkMemoryPropertyFlags flags = properties.memoryTypes[index].propertyFlags; + const bool preferred_match = (flags & preferred) == preferred; + if ((type_bits & (1U << index)) != 0 && (flags & required) == required && + (pass != 0 || preferred_match)) + { + selected_properties = flags; + return index; + } + } + } + throw Failure("no Vulkan memory type satisfies the required properties"); + } + + Buffer createBuffer(VkDeviceSize size, VkBufferUsageFlags usage, + VkMemoryPropertyFlags required, VkMemoryPropertyFlags preferred) + { + Buffer result; + result.mSize = size; + VkBufferCreateInfo create_info{ VK_STRUCTURE_TYPE_BUFFER_CREATE_INFO }; + create_info.size = size; + create_info.usage = usage; + create_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + check(vkCreateBuffer(mDevice, &create_info, nullptr, &result.mBuffer), "vkCreateBuffer"); + try + { + VkMemoryRequirements requirements{}; + vkGetBufferMemoryRequirements(mDevice, result.mBuffer, &requirements); + VkMemoryPropertyFlags selected = 0; + const std::uint32_t memory_type = + memoryType(requirements.memoryTypeBits, required, preferred, selected); + result.mAllocationSize = requirements.size; + result.mCoherent = (selected & VK_MEMORY_PROPERTY_HOST_COHERENT_BIT) != 0; + VkMemoryAllocateInfo allocation{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO }; + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = memory_type; + check(vkAllocateMemory(mDevice, &allocation, nullptr, &result.mMemory), "vkAllocateMemory(buffer)"); + check(vkBindBufferMemory(mDevice, result.mBuffer, result.mMemory, 0), "vkBindBufferMemory"); + return result; + } + catch (...) + { + vkDestroyBuffer(mDevice, result.mBuffer, nullptr); + if (result.mMemory != VK_NULL_HANDLE) + { + vkFreeMemory(mDevice, result.mMemory, nullptr); + } + throw; + } + } + + Image createImage(VkFormat format, Extent2D extent, VkImageUsageFlags usage) + { + Image result; + result.mFormat = format; + result.mExtent = extent; + result.mUsage = usage; + + VkImageCreateInfo create_info{ VK_STRUCTURE_TYPE_IMAGE_CREATE_INFO }; + create_info.imageType = VK_IMAGE_TYPE_2D; + create_info.format = format; + create_info.extent = { extent.mWidth, extent.mHeight, 1 }; + create_info.mipLevels = 1; + create_info.arrayLayers = 1; + create_info.samples = VK_SAMPLE_COUNT_1_BIT; + create_info.tiling = VK_IMAGE_TILING_OPTIMAL; + create_info.usage = usage; + create_info.sharingMode = VK_SHARING_MODE_EXCLUSIVE; + create_info.initialLayout = VK_IMAGE_LAYOUT_UNDEFINED; + check(vkCreateImage(mDevice, &create_info, nullptr, &result.mImage), "vkCreateImage"); + try + { + VkMemoryRequirements requirements{}; + vkGetImageMemoryRequirements(mDevice, result.mImage, &requirements); + VkMemoryPropertyFlags selected = 0; + const std::uint32_t memory_type = + memoryType(requirements.memoryTypeBits, 0, VK_MEMORY_PROPERTY_DEVICE_LOCAL_BIT, selected); + VkMemoryAllocateInfo allocation{ VK_STRUCTURE_TYPE_MEMORY_ALLOCATE_INFO }; + allocation.allocationSize = requirements.size; + allocation.memoryTypeIndex = memory_type; + check(vkAllocateMemory(mDevice, &allocation, nullptr, &result.mMemory), "vkAllocateMemory(image)"); + check(vkBindImageMemory(mDevice, result.mImage, result.mMemory, 0), "vkBindImageMemory"); + + VkImageViewCreateInfo view_info{ VK_STRUCTURE_TYPE_IMAGE_VIEW_CREATE_INFO }; + view_info.image = result.mImage; + view_info.viewType = VK_IMAGE_VIEW_TYPE_2D; + view_info.format = format; + view_info.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + view_info.subresourceRange.levelCount = 1; + view_info.subresourceRange.layerCount = 1; + check(vkCreateImageView(mDevice, &view_info, nullptr, &result.mView), "vkCreateImageView"); + return result; + } + catch (...) + { + if (result.mView != VK_NULL_HANDLE) + { + vkDestroyImageView(mDevice, result.mView, nullptr); + } + vkDestroyImage(mDevice, result.mImage, nullptr); + if (result.mMemory != VK_NULL_HANDLE) + { + vkFreeMemory(mDevice, result.mMemory, nullptr); + } + throw; + } + } + + void destroyBuffer(Buffer& buffer) noexcept + { + if (buffer.mBuffer != VK_NULL_HANDLE) + { + vkDestroyBuffer(mDevice, buffer.mBuffer, nullptr); + } + if (buffer.mMemory != VK_NULL_HANDLE) + { + vkFreeMemory(mDevice, buffer.mMemory, nullptr); + } + buffer = {}; + } + + void destroyImage(Image& image) noexcept + { + if (image.mView != VK_NULL_HANDLE) + { + vkDestroyImageView(mDevice, image.mView, nullptr); + } + if (image.mImage != VK_NULL_HANDLE) + { + vkDestroyImage(mDevice, image.mImage, nullptr); + } + if (image.mMemory != VK_NULL_HANDLE) + { + vkFreeMemory(mDevice, image.mMemory, nullptr); + } + image = {}; + } + + void writeBuffer(const Buffer& buffer, const void* source, std::size_t size, VkDeviceSize offset = 0) + { + if (offset > buffer.mSize || size > buffer.mSize - offset) + { + throw Failure("host write exceeds a Vulkan buffer"); + } + void* mapped = nullptr; + check(vkMapMemory(mDevice, buffer.mMemory, 0, VK_WHOLE_SIZE, 0, &mapped), "vkMapMemory(write)"); + std::memcpy(static_cast(mapped) + offset, source, size); + if (!buffer.mCoherent) + { + VkMappedMemoryRange range{ VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE }; + range.memory = buffer.mMemory; + range.offset = 0; + range.size = VK_WHOLE_SIZE; + const VkResult flush_result = vkFlushMappedMemoryRanges(mDevice, 1, &range); + vkUnmapMemory(mDevice, buffer.mMemory); + check(flush_result, "vkFlushMappedMemoryRanges"); + return; + } + vkUnmapMemory(mDevice, buffer.mMemory); + } + + std::vector readBuffer(const Buffer& buffer, std::size_t size) + { + if (size > buffer.mSize) + { + throw Failure("host read exceeds a Vulkan buffer"); + } + void* mapped = nullptr; + check(vkMapMemory(mDevice, buffer.mMemory, 0, VK_WHOLE_SIZE, 0, &mapped), "vkMapMemory(read)"); + if (!buffer.mCoherent) + { + VkMappedMemoryRange range{ VK_STRUCTURE_TYPE_MAPPED_MEMORY_RANGE }; + range.memory = buffer.mMemory; + range.offset = 0; + range.size = VK_WHOLE_SIZE; + const VkResult invalidate_result = vkInvalidateMappedMemoryRanges(mDevice, 1, &range); + if (invalidate_result != VK_SUCCESS) + { + vkUnmapMemory(mDevice, buffer.mMemory); + check(invalidate_result, "vkInvalidateMappedMemoryRanges"); + } + } + std::vector bytes(size); + std::memcpy(bytes.data(), mapped, size); + vkUnmapMemory(mDevice, buffer.mMemory); + return bytes; + } + + void submitImmediate(const std::function& commands) + { + check(vkResetCommandBuffer(mCommandBuffer, 0), "vkResetCommandBuffer(immediate)"); + VkCommandBufferBeginInfo begin{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_BEGIN_INFO }; + begin.flags = VK_COMMAND_BUFFER_USAGE_ONE_TIME_SUBMIT_BIT; + check(vkBeginCommandBuffer(mCommandBuffer, &begin), "vkBeginCommandBuffer(immediate)"); + commands(mCommandBuffer); + check(vkEndCommandBuffer(mCommandBuffer), "vkEndCommandBuffer(immediate)"); + VkSubmitInfo submit{ VK_STRUCTURE_TYPE_SUBMIT_INFO }; + submit.commandBufferCount = 1; + submit.pCommandBuffers = &mCommandBuffer; + check(vkQueueSubmit(mQueue, 1, &submit, VK_NULL_HANDLE), "vkQueueSubmit(immediate)"); + check(vkQueueWaitIdle(mQueue), "vkQueueWaitIdle(immediate)"); + } + + void createCommandResources() + { + VkCommandPoolCreateInfo pool_info{ VK_STRUCTURE_TYPE_COMMAND_POOL_CREATE_INFO }; + pool_info.flags = VK_COMMAND_POOL_CREATE_RESET_COMMAND_BUFFER_BIT; + pool_info.queueFamilyIndex = mQueueFamily; + check(vkCreateCommandPool(mDevice, &pool_info, nullptr, &mCommandPool), "vkCreateCommandPool"); + VkCommandBufferAllocateInfo allocation{ VK_STRUCTURE_TYPE_COMMAND_BUFFER_ALLOCATE_INFO }; + allocation.commandPool = mCommandPool; + allocation.level = VK_COMMAND_BUFFER_LEVEL_PRIMARY; + allocation.commandBufferCount = 1; + check(vkAllocateCommandBuffers(mDevice, &allocation, &mCommandBuffer), "vkAllocateCommandBuffers"); + + mTransferBuffer = createBuffer( + std::max(FIXTURE_STAGING_BYTES, RGBA16F_BYTES), + VK_BUFFER_USAGE_TRANSFER_SRC_BIT | VK_BUFFER_USAGE_TRANSFER_DST_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, + VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + } + + void createFixtureResources(const TonemapFixture& fixture) + { + if (fixture.mExtent.mWidth != LLRenderContract::TONEMAP_DIAGNOSTIC_WIDTH || + fixture.mExtent.mHeight != LLRenderContract::TONEMAP_DIAGNOSTIC_HEIGHT || + fixture.mRowOrigin != LLRenderContract::RowOrigin::BottomLeft) + { + throw Failure("shared tonemap fixture metadata is not canonical"); + } + + mScreenTriangle = createBuffer( + sizeof(fixture.mScreenTriangle), VK_BUFFER_USAGE_VERTEX_BUFFER_BIT, + VK_MEMORY_PROPERTY_HOST_VISIBLE_BIT, VK_MEMORY_PROPERTY_HOST_COHERENT_BIT); + writeBuffer(mScreenTriangle, fixture.mScreenTriangle.data(), sizeof(fixture.mScreenTriangle)); + + mScene = createImage(VK_FORMAT_R16G16B16A16_SFLOAT, fixture.mExtent, + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT); + mExposure = createImage(VK_FORMAT_R16_SFLOAT, { 1, 1 }, + VK_IMAGE_USAGE_TRANSFER_DST_BIT | VK_IMAGE_USAGE_SAMPLED_BIT); + + std::array upload{}; + std::memcpy(upload.data(), fixture.mSceneRGBA16F.data(), SCENE_BYTES); + std::memcpy(upload.data() + EXPOSURE_OFFSET, &fixture.mExposureR16F, + sizeof(fixture.mExposureR16F)); + writeBuffer(mTransferBuffer, upload.data(), upload.size()); + + submitImmediate([this](VkCommandBuffer command_buffer) + { + VkBufferMemoryBarrier host_barrier{ VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER }; + host_barrier.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT; + host_barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + host_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host_barrier.buffer = mTransferBuffer.mBuffer; + host_barrier.size = FIXTURE_STAGING_BYTES; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_HOST_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 0, nullptr, 1, &host_barrier, 0, nullptr); + + std::array to_transfer{}; + for (VkImageMemoryBarrier& barrier : to_transfer) + { + barrier.sType = VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER; + barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + barrier.subresourceRange.levelCount = 1; + barrier.subresourceRange.layerCount = 1; + } + to_transfer[0].image = mScene.mImage; + to_transfer[1].image = mExposure.mImage; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 0, nullptr, 0, nullptr, + static_cast(to_transfer.size()), to_transfer.data()); + + VkBufferImageCopy scene_copy{}; + scene_copy.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + scene_copy.imageSubresource.layerCount = 1; + scene_copy.imageExtent = { mScene.mExtent.mWidth, mScene.mExtent.mHeight, 1 }; + vkCmdCopyBufferToImage(command_buffer, mTransferBuffer.mBuffer, mScene.mImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &scene_copy); + + VkBufferImageCopy exposure_copy{}; + exposure_copy.bufferOffset = EXPOSURE_OFFSET; + exposure_copy.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + exposure_copy.imageSubresource.layerCount = 1; + exposure_copy.imageExtent = { 1, 1, 1 }; + vkCmdCopyBufferToImage(command_buffer, mTransferBuffer.mBuffer, mExposure.mImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, &exposure_copy); + + std::array to_shader = to_transfer; + for (VkImageMemoryBarrier& barrier : to_shader) + { + barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + barrier.dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + barrier.newLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + } + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT, 0, + 0, nullptr, 0, nullptr, + static_cast(to_shader.size()), to_shader.data()); + }); + } + + VkSampler createSampler(VkFilter filter) + { + VkSamplerCreateInfo create_info{ VK_STRUCTURE_TYPE_SAMPLER_CREATE_INFO }; + create_info.magFilter = filter; + create_info.minFilter = filter; + create_info.mipmapMode = VK_SAMPLER_MIPMAP_MODE_NEAREST; + create_info.addressModeU = VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT; + create_info.addressModeV = VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT; + create_info.addressModeW = VK_SAMPLER_ADDRESS_MODE_MIRRORED_REPEAT; + create_info.mipLodBias = 0.f; + create_info.anisotropyEnable = VK_FALSE; + create_info.maxAnisotropy = 1.f; + create_info.compareEnable = VK_FALSE; + create_info.compareOp = VK_COMPARE_OP_ALWAYS; + create_info.minLod = 0.f; + create_info.maxLod = 0.f; + create_info.borderColor = VK_BORDER_COLOR_FLOAT_TRANSPARENT_BLACK; + create_info.unnormalizedCoordinates = VK_FALSE; + VkSampler sampler = VK_NULL_HANDLE; + check(vkCreateSampler(mDevice, &create_info, nullptr, &sampler), "vkCreateSampler"); + return sampler; + } + + void createDescriptorResources() + { + mPointSampler = createSampler(VK_FILTER_NEAREST); + mLinearSampler = createSampler(VK_FILTER_LINEAR); + + std::array bindings{}; + for (std::uint32_t binding = 0; binding < bindings.size(); ++binding) + { + bindings[binding].binding = binding; + bindings[binding].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + bindings[binding].descriptorCount = 1; + bindings[binding].stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + } + VkDescriptorSetLayoutCreateInfo layout_info{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_LAYOUT_CREATE_INFO }; + layout_info.bindingCount = static_cast(bindings.size()); + layout_info.pBindings = bindings.data(); + check(vkCreateDescriptorSetLayout(mDevice, &layout_info, nullptr, &mDescriptorSetLayout), + "vkCreateDescriptorSetLayout"); + + VkPushConstantRange push_constant{}; + push_constant.stageFlags = VK_SHADER_STAGE_FRAGMENT_BIT; + push_constant.offset = 0; + push_constant.size = sizeof(LLRenderContract::TonemapParameters); + static_assert(sizeof(LLRenderContract::TonemapParameters) == 16); + VkPipelineLayoutCreateInfo pipeline_layout_info{ VK_STRUCTURE_TYPE_PIPELINE_LAYOUT_CREATE_INFO }; + pipeline_layout_info.setLayoutCount = 1; + pipeline_layout_info.pSetLayouts = &mDescriptorSetLayout; + pipeline_layout_info.pushConstantRangeCount = 1; + pipeline_layout_info.pPushConstantRanges = &push_constant; + check(vkCreatePipelineLayout(mDevice, &pipeline_layout_info, nullptr, &mPipelineLayout), + "vkCreatePipelineLayout"); + + VkDescriptorPoolSize pool_size{}; + pool_size.type = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + pool_size.descriptorCount = 2; + VkDescriptorPoolCreateInfo pool_info{ VK_STRUCTURE_TYPE_DESCRIPTOR_POOL_CREATE_INFO }; + pool_info.maxSets = 1; + pool_info.poolSizeCount = 1; + pool_info.pPoolSizes = &pool_size; + check(vkCreateDescriptorPool(mDevice, &pool_info, nullptr, &mDescriptorPool), + "vkCreateDescriptorPool"); + + VkDescriptorSetAllocateInfo allocation{ VK_STRUCTURE_TYPE_DESCRIPTOR_SET_ALLOCATE_INFO }; + allocation.descriptorPool = mDescriptorPool; + allocation.descriptorSetCount = 1; + allocation.pSetLayouts = &mDescriptorSetLayout; + check(vkAllocateDescriptorSets(mDevice, &allocation, &mDescriptorSet), + "vkAllocateDescriptorSets"); + + std::array image_info{}; + image_info[0] = { mPointSampler, mScene.mView, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL }; + image_info[1] = { mLinearSampler, mExposure.mView, VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL }; + std::array writes{}; + for (std::uint32_t binding = 0; binding < writes.size(); ++binding) + { + writes[binding].sType = VK_STRUCTURE_TYPE_WRITE_DESCRIPTOR_SET; + writes[binding].dstSet = mDescriptorSet; + writes[binding].dstBinding = binding; + writes[binding].descriptorCount = 1; + writes[binding].descriptorType = VK_DESCRIPTOR_TYPE_COMBINED_IMAGE_SAMPLER; + writes[binding].pImageInfo = &image_info[binding]; + } + vkUpdateDescriptorSets(mDevice, static_cast(writes.size()), writes.data(), 0, nullptr); + } + + void createDestinations() + { + for (PixelFormat format : { PixelFormat::RGBA8Unorm, PixelFormat::RGBA16Float }) + { + mDestinations.emplace_back(); + Destination& output = mDestinations.back(); + output.mContractFormat = format; + output.mImage = createImage(vkFormat(format), + { LLRenderContract::TONEMAP_DIAGNOSTIC_WIDTH, + LLRenderContract::TONEMAP_DIAGNOSTIC_HEIGHT }, + VK_IMAGE_USAGE_COLOR_ATTACHMENT_BIT | + VK_IMAGE_USAGE_SAMPLED_BIT | + VK_IMAGE_USAGE_TRANSFER_SRC_BIT | + VK_IMAGE_USAGE_TRANSFER_DST_BIT); + + VkAttachmentDescription attachment{}; + attachment.format = output.mImage.mFormat; + attachment.samples = VK_SAMPLE_COUNT_1_BIT; + attachment.loadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachment.storeOp = VK_ATTACHMENT_STORE_OP_STORE; + attachment.stencilLoadOp = VK_ATTACHMENT_LOAD_OP_DONT_CARE; + attachment.stencilStoreOp = VK_ATTACHMENT_STORE_OP_DONT_CARE; + attachment.initialLayout = VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL; + attachment.finalLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + VkAttachmentReference color_reference{ 0, VK_IMAGE_LAYOUT_COLOR_ATTACHMENT_OPTIMAL }; + VkSubpassDescription subpass{}; + subpass.pipelineBindPoint = VK_PIPELINE_BIND_POINT_GRAPHICS; + subpass.colorAttachmentCount = 1; + subpass.pColorAttachments = &color_reference; + std::array dependencies{}; + dependencies[0].srcSubpass = VK_SUBPASS_EXTERNAL; + dependencies[0].dstSubpass = 0; + dependencies[0].srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + dependencies[0].dstStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + dependencies[0].dstAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + dependencies[1].srcSubpass = 0; + dependencies[1].dstSubpass = VK_SUBPASS_EXTERNAL; + dependencies[1].srcStageMask = VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + dependencies[1].dstStageMask = VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + dependencies[1].srcAccessMask = VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + dependencies[1].dstAccessMask = VK_ACCESS_SHADER_READ_BIT; + VkRenderPassCreateInfo render_pass_info{ VK_STRUCTURE_TYPE_RENDER_PASS_CREATE_INFO }; + render_pass_info.attachmentCount = 1; + render_pass_info.pAttachments = &attachment; + render_pass_info.subpassCount = 1; + render_pass_info.pSubpasses = &subpass; + render_pass_info.dependencyCount = static_cast(dependencies.size()); + render_pass_info.pDependencies = dependencies.data(); + check(vkCreateRenderPass(mDevice, &render_pass_info, nullptr, &output.mRenderPass), + "vkCreateRenderPass"); + + VkFramebufferCreateInfo framebuffer_info{ VK_STRUCTURE_TYPE_FRAMEBUFFER_CREATE_INFO }; + framebuffer_info.renderPass = output.mRenderPass; + framebuffer_info.attachmentCount = 1; + framebuffer_info.pAttachments = &output.mImage.mView; + framebuffer_info.width = output.mImage.mExtent.mWidth; + framebuffer_info.height = output.mImage.mExtent.mHeight; + framebuffer_info.layers = 1; + check(vkCreateFramebuffer(mDevice, &framebuffer_info, nullptr, &output.mFramebuffer), + "vkCreateFramebuffer"); + } + } + + std::vector readSpirv(const std::filesystem::path& path) const + { + std::error_code file_error; + const std::uintmax_t file_size = std::filesystem::file_size(path, file_error); + if (file_error || file_size == 0 || file_size % sizeof(std::uint32_t) != 0 || + file_size > 16U * 1024U * 1024U) + { + throw Failure("SPIR-V file has an invalid size: " + path.string()); + } + std::ifstream input(path, std::ios::binary | std::ios::in); + if (!input) + { + throw Failure("cannot open SPIR-V file: " + path.string()); + } + std::vector words(static_cast(file_size) / sizeof(std::uint32_t)); + input.read(reinterpret_cast(words.data()), static_cast(file_size)); + if (!input || input.peek() != std::ifstream::traits_type::eof()) + { + throw Failure("cannot read complete SPIR-V file: " + path.string()); + } + return words; + } + + VkShaderModule createShaderModule(const std::filesystem::path& path) + { + const std::vector words = readSpirv(path); + VkShaderModuleCreateInfo create_info{ VK_STRUCTURE_TYPE_SHADER_MODULE_CREATE_INFO }; + create_info.codeSize = words.size() * sizeof(std::uint32_t); + create_info.pCode = words.data(); + VkShaderModule module = VK_NULL_HANDLE; + check(vkCreateShaderModule(mDevice, &create_info, nullptr, &module), "vkCreateShaderModule"); + return module; + } + + VkPipeline createPipeline(VkShaderModule vertex_module, VkShaderModule fragment_module, + VkRenderPass render_pass) + { + std::array stages{}; + stages[0].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[0].stage = VK_SHADER_STAGE_VERTEX_BIT; + stages[0].module = vertex_module; + stages[0].pName = "main"; + stages[1].sType = VK_STRUCTURE_TYPE_PIPELINE_SHADER_STAGE_CREATE_INFO; + stages[1].stage = VK_SHADER_STAGE_FRAGMENT_BIT; + stages[1].module = fragment_module; + stages[1].pName = "main"; + + VkVertexInputBindingDescription vertex_binding{}; + vertex_binding.binding = 0; + vertex_binding.stride = 16; + vertex_binding.inputRate = VK_VERTEX_INPUT_RATE_VERTEX; + VkVertexInputAttributeDescription vertex_attribute{}; + vertex_attribute.location = 0; + vertex_attribute.binding = 0; + vertex_attribute.format = VK_FORMAT_R32G32B32_SFLOAT; + vertex_attribute.offset = 0; + VkPipelineVertexInputStateCreateInfo vertex_input{ VK_STRUCTURE_TYPE_PIPELINE_VERTEX_INPUT_STATE_CREATE_INFO }; + vertex_input.vertexBindingDescriptionCount = 1; + vertex_input.pVertexBindingDescriptions = &vertex_binding; + vertex_input.vertexAttributeDescriptionCount = 1; + vertex_input.pVertexAttributeDescriptions = &vertex_attribute; + + VkPipelineInputAssemblyStateCreateInfo assembly{ VK_STRUCTURE_TYPE_PIPELINE_INPUT_ASSEMBLY_STATE_CREATE_INFO }; + assembly.topology = VK_PRIMITIVE_TOPOLOGY_TRIANGLE_LIST; + VkPipelineViewportStateCreateInfo viewport{ VK_STRUCTURE_TYPE_PIPELINE_VIEWPORT_STATE_CREATE_INFO }; + viewport.viewportCount = 1; + viewport.scissorCount = 1; + VkPipelineRasterizationStateCreateInfo rasterization{ VK_STRUCTURE_TYPE_PIPELINE_RASTERIZATION_STATE_CREATE_INFO }; + rasterization.polygonMode = VK_POLYGON_MODE_FILL; + rasterization.cullMode = VK_CULL_MODE_NONE; + rasterization.frontFace = VK_FRONT_FACE_COUNTER_CLOCKWISE; + rasterization.lineWidth = 1.f; + VkPipelineMultisampleStateCreateInfo multisample{ VK_STRUCTURE_TYPE_PIPELINE_MULTISAMPLE_STATE_CREATE_INFO }; + multisample.rasterizationSamples = VK_SAMPLE_COUNT_1_BIT; + VkPipelineDepthStencilStateCreateInfo depth{ VK_STRUCTURE_TYPE_PIPELINE_DEPTH_STENCIL_STATE_CREATE_INFO }; + depth.depthTestEnable = VK_FALSE; + depth.depthWriteEnable = VK_FALSE; + depth.depthCompareOp = VK_COMPARE_OP_LESS_OR_EQUAL; + VkPipelineColorBlendAttachmentState color_attachment{}; + color_attachment.colorWriteMask = VK_COLOR_COMPONENT_R_BIT | VK_COLOR_COMPONENT_G_BIT | + VK_COLOR_COMPONENT_B_BIT | VK_COLOR_COMPONENT_A_BIT; + VkPipelineColorBlendStateCreateInfo blend{ VK_STRUCTURE_TYPE_PIPELINE_COLOR_BLEND_STATE_CREATE_INFO }; + blend.attachmentCount = 1; + blend.pAttachments = &color_attachment; + constexpr std::array dynamic_states{ + VK_DYNAMIC_STATE_VIEWPORT, + VK_DYNAMIC_STATE_SCISSOR + }; + VkPipelineDynamicStateCreateInfo dynamic{ VK_STRUCTURE_TYPE_PIPELINE_DYNAMIC_STATE_CREATE_INFO }; + dynamic.dynamicStateCount = static_cast(dynamic_states.size()); + dynamic.pDynamicStates = dynamic_states.data(); + + VkGraphicsPipelineCreateInfo create_info{ VK_STRUCTURE_TYPE_GRAPHICS_PIPELINE_CREATE_INFO }; + create_info.stageCount = static_cast(stages.size()); + create_info.pStages = stages.data(); + create_info.pVertexInputState = &vertex_input; + create_info.pInputAssemblyState = &assembly; + create_info.pViewportState = &viewport; + create_info.pRasterizationState = &rasterization; + create_info.pMultisampleState = &multisample; + create_info.pDepthStencilState = &depth; + create_info.pColorBlendState = &blend; + create_info.pDynamicState = &dynamic; + create_info.layout = mPipelineLayout; + create_info.renderPass = render_pass; + create_info.subpass = 0; + create_info.basePipelineIndex = -1; + VkPipeline pipeline = VK_NULL_HANDLE; + check(vkCreateGraphicsPipelines(mDevice, VK_NULL_HANDLE, 1, &create_info, nullptr, &pipeline), + "vkCreateGraphicsPipelines"); + return pipeline; + } + + void createPipelines() + { + VkShaderModule vertex_module = VK_NULL_HANDLE; + std::array fragment_modules{}; + try + { + vertex_module = createShaderModule(mShaderDirectory / "tonemap.vert.spv"); + for (std::size_t offset = 0; offset < TONEMAP_VARIANTS.size(); ++offset) + { + const auto variant = static_cast(TONEMAP_VARIANTS[offset]); + fragment_modules[offset] = createShaderModule( + mShaderDirectory / ("tonemap.frag." + std::to_string(variant) + ".spv")); + } + for (Destination& output : mDestinations) + { + for (std::size_t offset = 0; offset < fragment_modules.size(); ++offset) + { + output.mPipelines[offset] = + createPipeline(vertex_module, fragment_modules[offset], output.mRenderPass); + } + } + } + catch (...) + { + for (VkShaderModule module : fragment_modules) + { + if (module != VK_NULL_HANDLE) + { + vkDestroyShaderModule(mDevice, module, nullptr); + } + } + if (vertex_module != VK_NULL_HANDLE) + { + vkDestroyShaderModule(mDevice, vertex_module, nullptr); + } + throw; + } + for (VkShaderModule module : fragment_modules) + { + vkDestroyShaderModule(mDevice, module, nullptr); + } + vkDestroyShaderModule(mDevice, vertex_module, nullptr); + } + + Destination& destination(PixelFormat format) + { + const auto found = std::find_if(mDestinations.begin(), mDestinations.end(), [format](const Destination& output) + { + return output.mContractFormat == format; + }); + if (found == mDestinations.end()) + { + throw Failure("destination format was not created"); + } + return *found; + } + + LLRenderVulkanTonemap::ExecutionContext executionContext() + { + return { mDevice, mCommandBuffer, mQueue, &mExecutorSubmissions }; + } + + LLRenderVulkanTonemap::Registry makeRegistry(const TonemapCase& diagnostic_case, + Destination& output, + RegistryMutation mutation) + { + const auto& handles = diagnostic_case.mInputs.mHandles; + LLRenderVulkanTonemap::Registry registry; + + auto scene_handle = handles.mScene; + if (mutation == RegistryMutation::StaleGeneration) + { + ++scene_handle.mGeneration; + } + LLRenderVulkanTonemap::ImageBinding destination_binding{ + output.mImage.mImage, + output.mImage.mView, + output.mImage.mFormat, + output.mImage.mExtent, + output.mImage.mUsage + }; + if (mutation == RegistryMutation::WrongExtent) + { + --destination_binding.mExtent.mWidth; + } + if (mutation == RegistryMutation::WrongFormat) + { + destination_binding.mFormat = VK_FORMAT_R16G16B16A16_SFLOAT; + } + + LLRenderVulkanTonemap::SamplerBinding point_sampler{ + mPointSampler, Filter::Nearest, Filter::Nearest, AddressMode::Mirror, AddressMode::Mirror + }; + if (mutation == RegistryMutation::WrongSampler) + { + point_sampler.mMinFilter = Filter::Linear; + } + const LLRenderVulkanTonemap::SamplerBinding linear_sampler{ + mLinearSampler, Filter::Linear, Filter::Linear, AddressMode::Mirror, AddressMode::Mirror + }; + + LLRenderVulkanTonemap::PipelineBinding pipeline; + pipeline.mProgram = { "deferred.tonemap", static_cast(diagnostic_case.mInputs.mVariant) }; + if (mutation == RegistryMutation::WrongProgram) + { + pipeline.mProgram.mName = "wrong.tonemap"; + } + if (mutation == RegistryMutation::WrongVariant) + { + pipeline.mProgram.mVariant = pipeline.mProgram.mVariant == 0 ? 1 : 0; + } + pipeline.mDestinationFormat = diagnostic_case.mInputs.mDestinationFormat; + pipeline.mExtent = diagnostic_case.mInputs.mDestinationExtent; + pipeline.mPipeline = output.mPipelines[variantOffset(diagnostic_case.mInputs.mVariant)]; + pipeline.mLayout = mPipelineLayout; + pipeline.mRenderPass = output.mRenderPass; + pipeline.mFramebuffer = output.mFramebuffer; + pipeline.mDescriptorSet = mDescriptorSet; + pipeline.mSceneView = mScene.mView; + pipeline.mExposureView = mExposure.mView; + pipeline.mDestinationView = output.mImage.mView; + pipeline.mPointSampler = mPointSampler; + pipeline.mLinearSampler = mLinearSampler; + pipeline.mDestinationFinalLayout = VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL; + pipeline.mDescriptorBindings = mutation == RegistryMutation::WrongDescriptors + ? std::vector{ 0 } + : std::vector{ 0, 1 }; + pipeline.mVertexStride = 16; + pipeline.mPositionFormat = VK_FORMAT_R32G32B32_SFLOAT; + pipeline.mPositionOffset = 0; + pipeline.mPushConstantSize = mutation == RegistryMutation::WrongParameterSize + ? 12 + : sizeof(LLRenderContract::TonemapParameters); + + const bool registered = + registry.addBuffer(handles.mScreenTriangle, + { mScreenTriangle.mBuffer, mScreenTriangle.mSize, + VK_BUFFER_USAGE_VERTEX_BUFFER_BIT }) && + registry.addImage(scene_handle, + { mScene.mImage, mScene.mView, mScene.mFormat, mScene.mExtent, mScene.mUsage }) && + registry.addImage(handles.mExposure, + { mExposure.mImage, mExposure.mView, mExposure.mFormat, + mExposure.mExtent, mExposure.mUsage }) && + registry.addImage(handles.mDestination, destination_binding) && + registry.addSampler(handles.mPointSampler, point_sampler) && + registry.addSampler(handles.mLinearSampler, linear_sampler) && + registry.addPipeline(handles.mPipeline, pipeline); + if (!registered) + { + throw Failure("could not register complete Vulkan tonemap resources"); + } + return registry; + } + + void seedDestination(Destination& output, const std::vector& bytes) + { + if (bytes.size() != outputByteCount(output.mContractFormat)) + { + throw Failure("destination seed has the wrong byte count"); + } + writeBuffer(mTransferBuffer, bytes.data(), bytes.size()); + submitImmediate([this, &output, size = bytes.size()](VkCommandBuffer command_buffer) + { + VkBufferMemoryBarrier host_barrier{ VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER }; + host_barrier.srcAccessMask = VK_ACCESS_HOST_WRITE_BIT; + host_barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + host_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host_barrier.buffer = mTransferBuffer.mBuffer; + host_barrier.size = size; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_HOST_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 0, nullptr, 1, &host_barrier, 0, nullptr); + + VkImageMemoryBarrier image_barrier{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER }; + image_barrier.dstAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + image_barrier.oldLayout = VK_IMAGE_LAYOUT_UNDEFINED; + image_barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + image_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + image_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + image_barrier.image = output.mImage.mImage; + image_barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + image_barrier.subresourceRange.levelCount = 1; + image_barrier.subresourceRange.layerCount = 1; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TOP_OF_PIPE_BIT, + VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 0, nullptr, 0, nullptr, 1, &image_barrier); + + VkBufferImageCopy copy{}; + copy.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + copy.imageSubresource.layerCount = 1; + copy.imageExtent = { output.mImage.mExtent.mWidth, output.mImage.mExtent.mHeight, 1 }; + vkCmdCopyBufferToImage(command_buffer, mTransferBuffer.mBuffer, output.mImage.mImage, + VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL, 1, ©); + }); + } + + std::vector readRaw(Destination& output, VkImageLayout old_layout) + { + const VkDeviceSize size = outputByteCount(output.mContractFormat); + submitImmediate([this, &output, old_layout, size](VkCommandBuffer command_buffer) + { + VkImageMemoryBarrier image_barrier{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER }; + const bool transfer_destination = old_layout == VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + if (!transfer_destination && old_layout != VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL) + { + throw Failure("readback image is not transfer-destination or shader-readable"); + } + image_barrier.srcAccessMask = transfer_destination + ? VK_ACCESS_TRANSFER_WRITE_BIT + : VK_ACCESS_COLOR_ATTACHMENT_WRITE_BIT; + image_barrier.dstAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + image_barrier.oldLayout = old_layout; + image_barrier.newLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + image_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + image_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + image_barrier.image = output.mImage.mImage; + image_barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + image_barrier.subresourceRange.levelCount = 1; + image_barrier.subresourceRange.layerCount = 1; + const VkPipelineStageFlags source_stage = + transfer_destination + ? VK_PIPELINE_STAGE_TRANSFER_BIT + : VK_PIPELINE_STAGE_COLOR_ATTACHMENT_OUTPUT_BIT; + vkCmdPipelineBarrier(command_buffer, source_stage, VK_PIPELINE_STAGE_TRANSFER_BIT, 0, + 0, nullptr, 0, nullptr, 1, &image_barrier); + + VkBufferImageCopy copy{}; + copy.imageSubresource.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + copy.imageSubresource.layerCount = 1; + copy.imageExtent = { output.mImage.mExtent.mWidth, output.mImage.mExtent.mHeight, 1 }; + vkCmdCopyImageToBuffer(command_buffer, output.mImage.mImage, + VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL, + mTransferBuffer.mBuffer, 1, ©); + + VkImageMemoryBarrier restore_barrier{ VK_STRUCTURE_TYPE_IMAGE_MEMORY_BARRIER }; + restore_barrier.srcAccessMask = VK_ACCESS_TRANSFER_READ_BIT; + restore_barrier.dstAccessMask = transfer_destination + ? VK_ACCESS_TRANSFER_WRITE_BIT + : VK_ACCESS_SHADER_READ_BIT; + restore_barrier.oldLayout = VK_IMAGE_LAYOUT_TRANSFER_SRC_OPTIMAL; + restore_barrier.newLayout = old_layout; + restore_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + restore_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + restore_barrier.image = output.mImage.mImage; + restore_barrier.subresourceRange.aspectMask = VK_IMAGE_ASPECT_COLOR_BIT; + restore_barrier.subresourceRange.levelCount = 1; + restore_barrier.subresourceRange.layerCount = 1; + const VkPipelineStageFlags restore_stage = transfer_destination + ? VK_PIPELINE_STAGE_TRANSFER_BIT + : VK_PIPELINE_STAGE_FRAGMENT_SHADER_BIT; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + restore_stage, 0, + 0, nullptr, 0, nullptr, 1, &restore_barrier); + + VkBufferMemoryBarrier host_barrier{ VK_STRUCTURE_TYPE_BUFFER_MEMORY_BARRIER }; + host_barrier.srcAccessMask = VK_ACCESS_TRANSFER_WRITE_BIT; + host_barrier.dstAccessMask = VK_ACCESS_HOST_READ_BIT; + host_barrier.srcQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host_barrier.dstQueueFamilyIndex = VK_QUEUE_FAMILY_IGNORED; + host_barrier.buffer = mTransferBuffer.mBuffer; + host_barrier.size = size; + vkCmdPipelineBarrier(command_buffer, VK_PIPELINE_STAGE_TRANSFER_BIT, + VK_PIPELINE_STAGE_HOST_BIT, 0, + 0, nullptr, 1, &host_barrier, 0, nullptr); + }); + return readBuffer(mTransferBuffer, static_cast(size)); + } + + std::vector readPixels(Destination& output, VkImageLayout old_layout) + { + const std::vector raw = readRaw(output, old_layout); + std::vector pixels; + pixels.reserve(LLRenderContract::TONEMAP_DIAGNOSTIC_COMPONENT_COUNT); + constexpr std::size_t width = LLRenderContract::TONEMAP_DIAGNOSTIC_WIDTH; + constexpr std::size_t height = LLRenderContract::TONEMAP_DIAGNOSTIC_HEIGHT; + constexpr std::size_t channels = LLRenderContract::TONEMAP_DIAGNOSTIC_CHANNELS; + if (output.mContractFormat == PixelFormat::RGBA8Unorm) + { + const std::size_t row_bytes = width * channels; + for (std::size_t bottom_row = 0; bottom_row < height; ++bottom_row) + { + const std::size_t source_row = height - 1 - bottom_row; + for (std::size_t component = 0; component < row_bytes; ++component) + { + pixels.push_back(static_cast(raw[source_row * row_bytes + component]) / 255.f); + } + } + return pixels; + } + const std::size_t row_bytes = width * channels * sizeof(std::uint16_t); + for (std::size_t bottom_row = 0; bottom_row < height; ++bottom_row) + { + const std::size_t source_row = height - 1 - bottom_row; + for (std::size_t offset = 0; offset < row_bytes; offset += sizeof(std::uint16_t)) + { + std::uint16_t bits = 0; + std::memcpy(&bits, raw.data() + source_row * row_bytes + offset, sizeof(bits)); + pixels.push_back(LLRenderContract::halfBitsToFloat(bits)); + } + } + return pixels; + } + + void runPreflight(const TonemapCase& diagnostic_case) + { + constexpr std::array mutations{ + RegistryMutation::StaleGeneration, + RegistryMutation::WrongProgram, + RegistryMutation::WrongVariant, + RegistryMutation::WrongExtent, + RegistryMutation::WrongFormat, + RegistryMutation::WrongSampler, + RegistryMutation::WrongDescriptors, + RegistryMutation::WrongParameterSize + }; + Destination& output = destination(PixelFormat::RGBA8Unorm); + std::vector sentinel(static_cast(RGBA8_BYTES)); + for (std::size_t offset = 0; offset < sentinel.size(); ++offset) + { + sentinel[offset] = static_cast((offset * 37U + 11U) & 0xffU); + } + + for (RegistryMutation mutation : mutations) + { + seedDestination(output, sentinel); + LLRenderVulkanTonemap::Registry registry = makeRegistry(diagnostic_case, output, mutation); + const std::uint64_t submissions_before = mExecutorSubmissions; + std::string execution_error; + const bool executed = LLRenderVulkanTonemap::execute( + diagnostic_case.mFrame, registry, executionContext(), execution_error); + const VkImageLayout read_layout = executed + ? VK_IMAGE_LAYOUT_SHADER_READ_ONLY_OPTIMAL + : VK_IMAGE_LAYOUT_TRANSFER_DST_OPTIMAL; + const std::vector after = readRaw(output, read_layout); + if (executed || execution_error.empty() || mExecutorSubmissions != submissions_before || + after != sentinel) + { + std::ostringstream message; + message << "preflight " << mutationName(mutation) + << " did not reject without submission and destination mutation"; + throw Failure(message.str()); + } + ++mPreflightCount; + } + } + + std::filesystem::path mShaderDirectory; + ValidationState mValidation; + VkInstance mInstance = VK_NULL_HANDLE; + VkDebugUtilsMessengerEXT mDebugMessenger = VK_NULL_HANDLE; + VkPhysicalDevice mPhysicalDevice = VK_NULL_HANDLE; + std::uint32_t mQueueFamily = 0; + VkDevice mDevice = VK_NULL_HANDLE; + VkQueue mQueue = VK_NULL_HANDLE; + VkCommandPool mCommandPool = VK_NULL_HANDLE; + VkCommandBuffer mCommandBuffer = VK_NULL_HANDLE; + Buffer mTransferBuffer; + Buffer mScreenTriangle; + Image mScene; + Image mExposure; + VkSampler mPointSampler = VK_NULL_HANDLE; + VkSampler mLinearSampler = VK_NULL_HANDLE; + VkDescriptorSetLayout mDescriptorSetLayout = VK_NULL_HANDLE; + VkPipelineLayout mPipelineLayout = VK_NULL_HANDLE; + VkDescriptorPool mDescriptorPool = VK_NULL_HANDLE; + VkDescriptorSet mDescriptorSet = VK_NULL_HANDLE; + std::vector mDestinations; + std::uint64_t mExecutorSubmissions = 0; + std::size_t mPreflightCount = 0; + bool mPortabilityEnumeration = false; + bool mPortabilitySubset = false; +}; + +int fail(const std::string& reason, const std::string& detail) +{ + std::cerr << "VULKAN_TONEMAP result=fail reason=" << reason; + if (!detail.empty()) + { + std::cerr << " detail={" << detail << '}'; + } + std::cerr << '\n'; + return 1; +} + +} // namespace + +int main(int argc, char** argv) +{ + Options options; + std::string option_error; + if (!parseOptions(argc, argv, options, option_error)) + { + fail("usage", option_error); + std::cerr << "usage: llvulkantonemap --shader-dir --output \n"; + return 2; + } + + try + { + std::error_code file_error; + if (std::filesystem::exists(options.mOutput, file_error) || file_error) + { + throw Failure(file_error ? "cannot inspect output path: " + file_error.message() + : "output artifact already exists: " + options.mOutput.string()); + } + VulkanTonemapRun runner(options.mShaderDirectory); + TonemapArtifact artifact = runner.run(); + runner.shutdown(); + if (runner.validationMessageCount() != 0) + { + std::ostringstream message; + message << runner.validationMessageCount() << " validation messages"; + const std::string first = runner.firstValidationMessage(); + if (!first.empty()) + { + message << "; first: " << first; + } + throw Failure(message.str()); + } + + std::string artifact_error; + if (!LLRenderContract::writeTonemapArtifact(options.mOutput, artifact, &artifact_error)) + { + throw Failure(artifact_error); + } + + std::cout << "VULKAN_TONEMAP result=pass" + << " cases=" << artifact.mCases.size() + << " preflight=" << runner.preflightCount() + << " submissions=" << runner.executorSubmissionCount() + << " validation_messages=" << runner.validationMessageCount() + << " portability_enumeration=" + << (runner.usedPortabilityEnumeration() ? "enabled" : "not_advertised") + << " portability_subset=" + << (runner.usedPortabilitySubset() ? "enabled" : "not_advertised") + << '\n'; + return 0; + } + catch (const CapabilityFailure& exception) + { + return fail("capability", exception.what()); + } + catch (const std::exception& exception) + { + return fail("execution", exception.what()); + } +} diff --git a/indra/llrender/vulkan/shaders/material.frag.glsl b/indra/llrender/vulkan/shaders/material.frag.glsl new file mode 100644 index 00000000000..331bac67ecd --- /dev/null +++ b/indra/llrender/vulkan/shaders/material.frag.glsl @@ -0,0 +1,59 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +#define LL_VULKAN_SHADER 1 +#define DIFFUSE_ALPHA_MODE 0 +#define HAS_NORMAL_MAP 1 +#define HAS_SPECULAR_MAP 1 +#define GBUFFER_FLAG_HAS_ATMOS 0.34 + +#if defined(LL_VULKAN_MATERIAL_PRODUCTION) && LL_VULKAN_MATERIAL_PRODUCTION == 1 +#define HAS_EMISSIVE 1 +#define HAS_SUN_SHADOW 1 +#define SUN_SHADOW 1 +#define SPOT_SHADOW 1 +#endif + +layout(location = 0) in vec3 vary_position; +layout(location = 1) in vec3 vary_tangent; +layout(location = 2) flat in float vary_sign; +layout(location = 3) in vec3 vary_normal; +layout(location = 4) in vec2 vary_texcoord1; +layout(location = 5) in vec2 vary_texcoord2; +layout(location = 6) in vec4 vertex_color; +layout(location = 7) in vec2 vary_texcoord0; + +#if defined(LL_VULKAN_MATERIAL_PRODUCTION) && LL_VULKAN_MATERIAL_PRODUCTION == 1 +layout(location = 0) out vec4 frag_data[4]; +#else +layout(location = 0) out vec4 frag_data[3]; +#endif + +layout(set = 1, binding = 0) uniform sampler2D diffuseMap; +layout(set = 1, binding = 1) uniform sampler2D bumpMap; +layout(set = 1, binding = 2) uniform sampler2D specularMap; + +layout(std140, set = 0, binding = 0) uniform MaterialParameterPacket +{ + vec4 material_parameter_vectors[17]; +}; + +float materialParameterWord(int index) +{ + return material_parameter_vectors[index / 4][index % 4]; +} + +vec4 materialParameterVec4(int first) +{ + return vec4(materialParameterWord(first + 0), materialParameterWord(first + 1), + materialParameterWord(first + 2), materialParameterWord(first + 3)); +} + +#define specular_color materialParameterVec4(57) +#define clipPlane materialParameterVec4(61) +#define env_intensity materialParameterWord(65) +#define emissive_brightness materialParameterWord(66) +#define mirror_flag materialParameterWord(67) + +#include "../../../newview/app_settings/shaders/class1/deferred/globalF.glsl" +#include "../../../newview/app_settings/shaders/class3/deferred/materialF.glsl" diff --git a/indra/llrender/vulkan/shaders/material.vert.glsl b/indra/llrender/vulkan/shaders/material.vert.glsl new file mode 100644 index 00000000000..be36969d682 --- /dev/null +++ b/indra/llrender/vulkan/shaders/material.vert.glsl @@ -0,0 +1,79 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +#define LL_VULKAN_SHADER 1 +#define DIFFUSE_ALPHA_MODE 0 +#define HAS_NORMAL_MAP 1 +#define HAS_SPECULAR_MAP 1 + +#if defined(LL_VULKAN_MATERIAL_PRODUCTION) && LL_VULKAN_MATERIAL_PRODUCTION == 1 +#define HAS_EMISSIVE 1 +#define HAS_SUN_SHADOW 1 +#define SUN_SHADOW 1 +#define SPOT_SHADOW 1 +#endif + +layout(location = 0) in vec3 position; +layout(location = 1) in vec3 normal; +layout(location = 2) in vec2 texcoord0; +layout(location = 3) in vec4 diffuse_color; +layout(location = 4) in vec4 tangent; +layout(location = 5) in vec2 texcoord1; +layout(location = 6) in vec2 texcoord2; + +layout(location = 0) out vec3 vary_position; +layout(location = 1) out vec3 vary_tangent; +layout(location = 2) flat out float vary_sign; +layout(location = 3) out vec3 vary_normal; +layout(location = 4) out vec2 vary_texcoord1; +layout(location = 5) out vec2 vary_texcoord2; +layout(location = 6) out vec4 vertex_color; +layout(location = 7) out vec2 vary_texcoord0; + +// MaterialParameters is 68 tightly packed floats. vec4[17] keeps those bytes +// contiguous under std140, including the mat3-to-mat4 boundary at word 41. +layout(std140, set = 0, binding = 0) uniform MaterialParameterPacket +{ + vec4 material_parameter_vectors[17]; +}; + +float materialParameterWord(int index) +{ + return material_parameter_vectors[index / 4][index % 4]; +} + +mat4 materialParameterMat4(int first) +{ + return mat4( + vec4(materialParameterWord(first + 0), materialParameterWord(first + 1), + materialParameterWord(first + 2), materialParameterWord(first + 3)), + vec4(materialParameterWord(first + 4), materialParameterWord(first + 5), + materialParameterWord(first + 6), materialParameterWord(first + 7)), + vec4(materialParameterWord(first + 8), materialParameterWord(first + 9), + materialParameterWord(first + 10), materialParameterWord(first + 11)), + vec4(materialParameterWord(first + 12), materialParameterWord(first + 13), + materialParameterWord(first + 14), materialParameterWord(first + 15))); +} + +mat3 materialParameterMat3(int first) +{ + return mat3( + vec3(materialParameterWord(first + 0), materialParameterWord(first + 1), materialParameterWord(first + 2)), + vec3(materialParameterWord(first + 3), materialParameterWord(first + 4), materialParameterWord(first + 5)), + vec3(materialParameterWord(first + 6), materialParameterWord(first + 7), materialParameterWord(first + 8))); +} + +#define modelview_matrix materialParameterMat4(0) +#define modelview_projection_matrix materialParameterMat4(16) +#define normal_matrix materialParameterMat3(32) +#define texture_matrix0 materialParameterMat4(41) + +#define main llMaterialVertexMain +#include "../../../newview/app_settings/shaders/class1/deferred/materialV.glsl" +#undef main + +void main() +{ + llMaterialVertexMain(); + gl_Position.z = 0.5 * (gl_Position.z + gl_Position.w); +} diff --git a/indra/llrender/vulkan/shaders/textureupload.frag.glsl b/indra/llrender/vulkan/shaders/textureupload.frag.glsl new file mode 100644 index 00000000000..a6bbf683d25 --- /dev/null +++ b/indra/llrender/vulkan/shaders/textureupload.frag.glsl @@ -0,0 +1,11 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +#define LL_VULKAN_SHADER 1 + +layout(location = 0) in vec2 tc; +layout(location = 0) out vec4 frag_color; + +layout(set = 0, binding = 0) uniform sampler2D diffuseMap; + +#include "../../../newview/app_settings/shaders/class1/interface/copyF.glsl" diff --git a/indra/llrender/vulkan/shaders/textureupload.vert.glsl b/indra/llrender/vulkan/shaders/textureupload.vert.glsl new file mode 100644 index 00000000000..8411eb69bd1 --- /dev/null +++ b/indra/llrender/vulkan/shaders/textureupload.vert.glsl @@ -0,0 +1,9 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +#define LL_VULKAN_SHADER 1 + +layout(location = 0) in vec3 position; +layout(location = 0) out vec2 tc; + +#include "../../../newview/app_settings/shaders/class1/interface/copyV.glsl" diff --git a/indra/llrender/vulkan/shaders/tonemap.frag.glsl b/indra/llrender/vulkan/shaders/tonemap.frag.glsl new file mode 100644 index 00000000000..96eae3daee8 --- /dev/null +++ b/indra/llrender/vulkan/shaders/tonemap.frag.glsl @@ -0,0 +1,39 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +#define LL_VULKAN_SHADER 1 + +layout(location = 0) in vec2 vary_fragcoord; +layout(location = 0) out vec4 frag_color; + +layout(set = 0, binding = 0) uniform sampler2D diffuseRect; +layout(set = 0, binding = 1) uniform sampler2D exposureMap; + +layout(push_constant, std430) uniform TonemapParameters +{ + layout(offset = 0) float exposure; + layout(offset = 4) float tonemap_mix; + layout(offset = 8) int tonemap_type; + layout(offset = 12) float gamma; +}; + +#include "../../../newview/app_settings/shaders/class1/environment/srgbF.glsl" +#include "../../../newview/app_settings/shaders/class1/deferred/tonemapUtilF.glsl" + +#if defined(LL_TONEMAP_LEGACY_GAMMA) && !defined(LL_TONEMAP_GAMMA_CORRECT) +#error "LL_TONEMAP_LEGACY_GAMMA requires LL_TONEMAP_GAMMA_CORRECT" +#endif + +#ifdef LL_TONEMAP_NO_POST +#define NO_POST 1 +#endif + +#ifdef LL_TONEMAP_GAMMA_CORRECT +#define GAMMA_CORRECT 1 +#endif + +#ifdef LL_TONEMAP_LEGACY_GAMMA +#define LEGACY_GAMMA 1 +#endif + +#include "../../../newview/app_settings/shaders/class1/deferred/postDeferredTonemap.glsl" diff --git a/indra/llrender/vulkan/shaders/tonemap.vert.glsl b/indra/llrender/vulkan/shaders/tonemap.vert.glsl new file mode 100644 index 00000000000..6da35938d91 --- /dev/null +++ b/indra/llrender/vulkan/shaders/tonemap.vert.glsl @@ -0,0 +1,9 @@ +#version 450 +#extension GL_GOOGLE_include_directive : require + +#define LL_VULKAN_SHADER 1 + +layout(location = 0) in vec3 position; +layout(location = 0) out vec2 vary_fragcoord; + +#include "../../../newview/app_settings/shaders/class1/deferred/postDeferredNoTCV.glsl" diff --git a/indra/llrender/vulkan/test_verify_material_reflection.py b/indra/llrender/vulkan/test_verify_material_reflection.py new file mode 100644 index 00000000000..54a85572ec9 --- /dev/null +++ b/indra/llrender/vulkan/test_verify_material_reflection.py @@ -0,0 +1,240 @@ +#!/usr/bin/env python3 +"""Focused regression tests for material profile reflection validation.""" + +from __future__ import annotations + +import copy +import unittest +from typing import Any + +from verify_material_reflection import VerificationError, verify + + +def _fixture() -> tuple[dict[str, Any], dict[str, Any], dict[str, Any], str]: + expectation = { + "schema": 1, + "entry_points": [ + {"module": "vertex", "name": "main", "stage": "vert"}, + {"module": "fragment", "name": "main", "stage": "frag"}, + ], + "vertex_inputs": [{"name": "position", "location": 0, "type": "vec3"}], + "interstage_variables": [ + {"name": "vary_position", "location": 0, "type": "vec3"}, + {"name": "vary_sign", "location": 2, "type": "float"}, + ], + "uniform_blocks": [ + { + "name": "MaterialParams", + "set": 0, + "binding": 0, + "size": 272, + "stages": ["vertex", "fragment"], + } + ], + "combined_image_samplers": [ + { + "name": "diffuseMap", + "set": 1, + "binding": 0, + "type": "sampler2D", + "stages": ["fragment"], + } + ], + "fragment_outputs": [{"name": "frag_data", "location": 0, "type": "vec4"}], + "push_constant_ranges": [], + "flat_interfaces": [ + { + "name": "vary_sign", + "location": 2, + "modules": ["vertex", "fragment"], + } + ], + } + shared_block = { + "name": "MaterialParams", + "set": 0, + "binding": 0, + "block_size": 272, + } + vertex = { + "entryPoints": [{"name": "main", "mode": "vert"}], + "inputs": [{"name": "position", "location": 0, "type": "vec3"}], + "outputs": [ + {"name": "vary_position", "location": 0, "type": "vec3"}, + {"name": "vary_sign", "location": 2, "type": "float"}, + ], + "ubos": [shared_block], + } + fragment = { + "entryPoints": [{"name": "main", "mode": "frag"}], + "inputs": [ + {"name": "vary_position", "location": 0, "type": "vec3"}, + {"name": "vary_sign", "location": 2, "type": "float"}, + ], + "outputs": [{"name": "frag_data", "location": 0, "type": "vec4"}], + "ubos": [shared_block], + "textures": [ + { + "name": "diffuseMap", + "set": 1, + "binding": 0, + "type": "sampler2D", + } + ], + } + disassembly = "\n".join( + ( + 'OpName %smooth "vary_position"', + "OpDecorate %smooth Location 0", + 'OpName %vary "vary_sign"', + "OpDecorate %vary Flat", + "OpDecorate %vary Location 2", + ) + ) + return expectation, vertex, fragment, disassembly + + +class ReflectionGateTests(unittest.TestCase): + def setUp(self) -> None: + fixture = _fixture() + self.expectation, self.vertex, self.fragment, self.disassembly = copy.deepcopy( + fixture + ) + + def verify(self) -> None: + verify( + self.expectation, + self.vertex, + self.fragment, + self.disassembly, + self.disassembly, + ) + + def test_minimal_valid_fixture_passes(self) -> None: + self.verify() + + def test_swapped_entry_point_stages_are_rejected(self) -> None: + self.vertex["entryPoints"][0]["mode"] = "frag" + self.fragment["entryPoints"][0]["mode"] = "vert" + + with self.assertRaisesRegex(VerificationError, "entry points"): + self.verify() + + def test_descriptor_arrays_are_rejected(self) -> None: + self.fragment["textures"][0].update( + {"array": [2], "array_size_is_literal": [True]} + ) + + with self.assertRaisesRegex(VerificationError, "descriptor array"): + self.verify() + + def test_unexpected_push_constants_are_rejected(self) -> None: + self.fragment["push_constants"] = [{"name": "unexpected"}] + + with self.assertRaisesRegex(VerificationError, "unexpected push_constants"): + self.verify() + + def test_missing_flat_decoration_is_rejected(self) -> None: + self.disassembly = "\n".join( + ( + 'OpName %vary "vary_sign"', + "OpDecorate %vary Location 2", + ) + ) + + with self.assertRaisesRegex(VerificationError, "interpolation decorations"): + self.verify() + + def test_unexpected_interpolation_decorations_are_rejected(self) -> None: + for interpolation in ("NoPerspective", "Centroid", "Sample"): + with self.subTest(interpolation=interpolation): + changed = f"{self.disassembly}\nOpDecorate %smooth {interpolation}" + with self.assertRaisesRegex( + VerificationError, "interpolation decorations" + ): + verify( + self.expectation, + self.vertex, + self.fragment, + changed, + self.disassembly, + ) + + def test_interpolation_on_unlocated_data_is_rejected(self) -> None: + self.disassembly += "\n".join( + ( + "", + 'OpName %internal "internal"', + "OpDecorate %internal NoPerspective", + ) + ) + + with self.assertRaisesRegex(VerificationError, "without Location"): + self.verify() + + def test_diagnostic_outputs_cannot_satisfy_production_expectation(self) -> None: + output_prototype = self.expectation["fragment_outputs"][0] + diagnostic_outputs = [ + {**output_prototype, "location": output_prototype["location"] + offset} + for offset in range(3) + ] + self.expectation["fragment_outputs"] = diagnostic_outputs + self.fragment["outputs"] = [ + { + **output_prototype, + "array": [len(diagnostic_outputs)], + "array_size_is_literal": [True], + } + ] + self.verify() + + production_expectation = copy.deepcopy(self.expectation) + last_output = production_expectation["fragment_outputs"][-1] + production_expectation["fragment_outputs"].append( + {**last_output, "location": last_output["location"] + 1} + ) + + with self.assertRaisesRegex(VerificationError, "fragment outputs"): + verify( + production_expectation, + self.vertex, + self.fragment, + self.disassembly, + self.disassembly, + ) + + def test_output_array_requires_literal_metadata(self) -> None: + self.fragment["outputs"][0]["array"] = [1] + + with self.assertRaisesRegex( + VerificationError, "array_size_is_literal must be a JSON array" + ): + self.verify() + + def test_output_array_rejects_malformed_literal_metadata(self) -> None: + self.fragment["outputs"][0].update( + {"array": [1], "array_size_is_literal": "true"} + ) + + with self.assertRaisesRegex( + VerificationError, "array_size_is_literal must be a JSON array" + ): + self.verify() + + def test_output_array_rejects_mismatched_literal_metadata(self) -> None: + self.fragment["outputs"][0].update({"array": [1], "array_size_is_literal": []}) + + with self.assertRaisesRegex(VerificationError, "must match"): + self.verify() + + def test_output_array_rejects_nonliteral_dimension(self) -> None: + self.fragment["outputs"][0].update( + {"array": [1], "array_size_is_literal": [False]} + ) + + with self.assertRaisesRegex(VerificationError, "must be true"): + self.verify() + + +if __name__ == "__main__": + unittest.main() diff --git a/indra/llrender/vulkan/verify_material_reflection.py b/indra/llrender/vulkan/verify_material_reflection.py new file mode 100644 index 00000000000..95ed27bfa66 --- /dev/null +++ b/indra/llrender/vulkan/verify_material_reflection.py @@ -0,0 +1,532 @@ +#!/usr/bin/env python3 +"""Verify a material SPIR-V interface against its profile manifest dump.""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from collections.abc import Iterable, Mapping, Sequence +from pathlib import Path +from typing import Any, NoReturn + + +class VerificationError(Exception): + """A reflection input or interface failed validation.""" + + +_RESOURCE_COLLECTIONS = ( + "ubos", + "ssbos", + "textures", + "separate_images", + "separate_samplers", + "images", + "storage_images", + "atomic_counters", + "acceleration_structures", + "push_constants", + "subpass_inputs", + "specialization_constants", +) + +_REFLECTION_FIELDS = { + "entryPoints", + "types", + "inputs", + "outputs", + *_RESOURCE_COLLECTIONS, +} + + +def _fail(message: str) -> NoReturn: + raise VerificationError(message) + + +def _object(value: Any, context: str) -> Mapping[str, Any]: + if not isinstance(value, dict): + _fail(f"{context} must be a JSON object") + return value + + +def _array(value: Any, context: str) -> Sequence[Any]: + if not isinstance(value, list): + _fail(f"{context} must be a JSON array") + return value + + +def _string(value: Any, context: str) -> str: + if not isinstance(value, str): + _fail(f"{context} must be a string") + return value + + +def _integer(value: Any, context: str) -> int: + if not isinstance(value, int) or isinstance(value, bool): + _fail(f"{context} must be an integer") + return value + + +def _strings(value: Any, context: str) -> tuple[str, ...]: + result = tuple( + _string(item, f"{context}[{index}]") + for index, item in enumerate(_array(value, context)) + ) + if len(result) != len(set(result)): + _fail(f"{context} contains duplicates") + return tuple(sorted(result)) + + +def _load_json(path: Path, label: str) -> Mapping[str, Any]: + try: + with path.open("r", encoding="utf-8") as stream: + return _object(json.load(stream), label) + except OSError as error: + _fail(f"cannot read {label} {path}: {error}") + except json.JSONDecodeError as error: + _fail(f"invalid JSON in {label} {path}: {error}") + + +def _load_text(path: Path, label: str) -> str: + try: + return path.read_text(encoding="utf-8") + except OSError as error: + _fail(f"cannot read {label} {path}: {error}") + + +def _format_rows(rows: Iterable[tuple[Any, ...]]) -> str: + return "[" + ", ".join(repr(row) for row in sorted(rows)) + "]" + + +def _expect_equal( + label: str, actual: set[tuple[Any, ...]], expected: set[tuple[Any, ...]] +) -> None: + if actual == expected: + return + missing = expected - actual + extra = actual - expected + details: list[str] = [] + if missing: + details.append(f"missing {_format_rows(missing)}") + if extra: + details.append(f"unexpected {_format_rows(extra)}") + _fail(f"{label}: {'; '.join(details)}") + + +def _unique_rows(rows: Iterable[tuple[Any, ...]], label: str) -> set[tuple[Any, ...]]: + materialized = list(rows) + unique = set(materialized) + if len(unique) != len(materialized): + _fail(f"{label} contains duplicate entries") + return unique + + +def _expectation_rows( + expectation: Mapping[str, Any], + field: str, + scalar_fields: Sequence[str], + *, + stages: bool = False, +) -> set[tuple[Any, ...]]: + result: list[tuple[Any, ...]] = [] + for index, raw in enumerate(_array(expectation.get(field), f"expectation.{field}")): + item = _object(raw, f"expectation.{field}[{index}]") + values: list[Any] = [] + allowed = set(scalar_fields) + for name in scalar_fields: + value = item.get(name) + if name in {"location", "set", "binding", "size", "count"}: + values.append(_integer(value, f"expectation.{field}[{index}].{name}")) + else: + values.append(_string(value, f"expectation.{field}[{index}].{name}")) + if stages: + values.append( + _strings(item.get("stages"), f"expectation.{field}[{index}].stages") + ) + allowed.add("stages") + unknown = set(item) - allowed + if unknown: + _fail(f"expectation.{field}[{index}] has unknown fields {sorted(unknown)}") + result.append(tuple(values)) + return _unique_rows(result, f"expectation.{field}") + + +def _reflection_items( + reflection: Mapping[str, Any], field: str, module: str +) -> Sequence[Any]: + value = reflection.get(field, []) + return _array(value, f"{module} reflection.{field}") + + +def _reflection_io( + reflection: Mapping[str, Any], field: str, module: str, *, expand_arrays: bool +) -> set[tuple[str, int, str]]: + rows: list[tuple[str, int, str]] = [] + for index, raw in enumerate(_reflection_items(reflection, field, module)): + item = _object(raw, f"{module} reflection.{field}[{index}]") + name = _string(item.get("name"), f"{module} reflection.{field}[{index}].name") + location = _integer( + item.get("location"), f"{module} reflection.{field}[{index}].location" + ) + value_type = _string( + item.get("type"), f"{module} reflection.{field}[{index}].type" + ) + count = 1 + if "array" in item: + dimensions = _array( + item["array"], f"{module} reflection.{field}[{index}].array" + ) + if not expand_arrays or len(dimensions) != 1: + _fail( + f"{module} reflection.{field}[{index}] has an unsupported array shape" + ) + literal_context = ( + f"{module} reflection.{field}[{index}].array_size_is_literal" + ) + literal_dimensions = _array( + item.get("array_size_is_literal"), literal_context + ) + if len(literal_dimensions) != len(dimensions): + _fail(f"{literal_context} must match the array dimensions") + for dimension, literal in enumerate(literal_dimensions): + if not isinstance(literal, bool): + _fail(f"{literal_context}[{dimension}] must be a boolean") + if not literal: + _fail(f"{literal_context}[{dimension}] must be true") + count = _integer( + dimensions[0], f"{module} reflection.{field}[{index}].array[0]" + ) + if count <= 0: + _fail( + f"{module} reflection.{field}[{index}] has a non-positive array size" + ) + rows.extend((name, location + offset, value_type) for offset in range(count)) + return _unique_rows(rows, f"{module} reflection.{field}") + + +def _entry_point(reflection: Mapping[str, Any], module: str) -> tuple[str, str]: + entries = _reflection_items(reflection, "entryPoints", module) + if len(entries) != 1: + _fail( + f"{module} reflection must contain exactly one entry point, found {len(entries)}" + ) + entry = _object(entries[0], f"{module} reflection.entryPoints[0]") + return ( + _string(entry.get("name"), f"{module} reflection.entryPoints[0].name"), + _string(entry.get("mode"), f"{module} reflection.entryPoints[0].mode"), + ) + + +def _resources( + reflections: Mapping[str, Mapping[str, Any]], + field: str, + *, + include_name: bool = False, + include_size: bool = False, + include_type: bool = False, +) -> set[tuple[Any, ...]]: + combined: dict[tuple[Any, ...], set[str]] = {} + seen_in_module: set[tuple[str, tuple[Any, ...]]] = set() + for module, reflection in reflections.items(): + for index, raw in enumerate(_reflection_items(reflection, field, module)): + item = _object(raw, f"{module} reflection.{field}[{index}]") + if "array" in item or "array_size_is_literal" in item: + _fail( + f"{module} reflection.{field}[{index}] is an unsupported descriptor array" + ) + key: list[Any] = [] + if include_name: + key.append( + _string( + item.get("name"), f"{module} reflection.{field}[{index}].name" + ) + ) + key.extend( + [ + _integer( + item.get("set"), f"{module} reflection.{field}[{index}].set" + ), + _integer( + item.get("binding"), + f"{module} reflection.{field}[{index}].binding", + ), + ] + ) + if include_size: + key.append( + _integer( + item.get("block_size"), + f"{module} reflection.{field}[{index}].block_size", + ) + ) + if include_type: + key.append( + _string( + item.get("type"), f"{module} reflection.{field}[{index}].type" + ) + ) + frozen_key = tuple(key) + module_key = (module, frozen_key) + if module_key in seen_in_module: + _fail( + f"{module} reflection.{field} contains duplicate resource {frozen_key!r}" + ) + seen_in_module.add(module_key) + combined.setdefault(frozen_key, set()).add(module) + return {(*key, tuple(sorted(stages))) for key, stages in combined.items()} + + +def _reject_unexpected_resource_categories( + reflections: Mapping[str, Mapping[str, Any]], +) -> None: + permitted_nonempty = {"ubos", "textures"} + for module, reflection in reflections.items(): + unknown = set(reflection) - _REFLECTION_FIELDS + if unknown: + _fail(f"{module} reflection has unknown top-level fields {sorted(unknown)}") + for field in _RESOURCE_COLLECTIONS: + items = _reflection_items(reflection, field, module) + if items and field not in permitted_nonempty: + _fail( + f"{module} reflection has unexpected {field}: {len(items)} entr{'y' if len(items) == 1 else 'ies'}" + ) + + +_OP_NAME = re.compile(r'^\s*OpName\s+(%\S+)\s+"((?:[^"\\]|\\.)*)"\s*$') +_OP_INTERPOLATION = re.compile( + r"^\s*OpDecorate\s+(%\S+)\s+(Flat|NoPerspective|Centroid|Sample)\s*$" +) +_OP_LOCATION = re.compile(r"^\s*OpDecorate\s+(%\S+)\s+Location\s+(\d+)\s*$") + + +def _disassembly_decorations( + text: str, module: str +) -> tuple[dict[str, str], set[tuple[str, str]], dict[str, int]]: + names: dict[str, str] = {} + interpolations: set[tuple[str, str]] = set() + locations: dict[str, int] = {} + for line in text.splitlines(): + if match := _OP_NAME.match(line): + identifier, encoded_name = match.groups() + try: + name = json.loads(f'"{encoded_name}"') + except json.JSONDecodeError as error: + _fail(f"{module} disassembly has an invalid OpName string: {error}") + if identifier in names: + _fail(f"{module} disassembly names {identifier} more than once") + names[identifier] = name + elif match := _OP_INTERPOLATION.match(line): + identifier, interpolation = match.groups() + if (identifier, interpolation) in interpolations: + _fail( + f"{module} disassembly decorates {identifier} {interpolation} more than once" + ) + interpolations.add((identifier, interpolation)) + elif match := _OP_LOCATION.match(line): + identifier, raw_location = match.groups() + if identifier in locations: + _fail(f"{module} disassembly gives {identifier} more than one Location") + locations[identifier] = int(raw_location) + return names, interpolations, locations + + +def _verify_interpolation_decorations( + expectation: Mapping[str, Any], disassemblies: Mapping[str, str] +) -> None: + expected_by_module: dict[str, set[tuple[str, int, str]]] = { + "vertex": set(), + "fragment": set(), + } + raw_interfaces = _array( + expectation.get("flat_interfaces"), "expectation.flat_interfaces" + ) + for index, raw in enumerate(raw_interfaces): + item = _object(raw, f"expectation.flat_interfaces[{index}]") + name = _string(item.get("name"), f"expectation.flat_interfaces[{index}].name") + location = _integer( + item.get("location"), f"expectation.flat_interfaces[{index}].location" + ) + modules = _strings( + item.get("modules"), f"expectation.flat_interfaces[{index}].modules" + ) + if not modules or set(modules) - set(expected_by_module): + _fail( + f"expectation.flat_interfaces[{index}].modules must contain vertex and/or fragment" + ) + unknown = set(item) - {"name", "location", "modules"} + if unknown: + _fail( + f"expectation.flat_interfaces[{index}] has unknown fields {sorted(unknown)}" + ) + for module in modules: + expected_by_module[module].add((name, location, "Flat")) + + for module, text in disassemblies.items(): + names, interpolations, locations = _disassembly_decorations(text, module) + actual: set[tuple[str, int, str]] = set() + for identifier, interpolation in interpolations: + if identifier not in names: + _fail( + f"{module} disassembly has {interpolation} on unnamed ID {identifier}" + ) + if identifier not in locations: + _fail( + f"{module} disassembly has {interpolation} on {names[identifier]!r} without Location" + ) + actual.add((names[identifier], locations[identifier], interpolation)) + _expect_equal( + f"{module} interpolation decorations", actual, expected_by_module[module] + ) + + +def verify( + expectation: Mapping[str, Any], + vertex: Mapping[str, Any], + fragment: Mapping[str, Any], + vertex_disassembly: str, + fragment_disassembly: str, +) -> None: + allowed_expectation_fields = { + "schema", + "entry_points", + "vertex_inputs", + "interstage_variables", + "uniform_blocks", + "combined_image_samplers", + "fragment_outputs", + "push_constant_ranges", + "flat_interfaces", + } + unknown = set(expectation) - allowed_expectation_fields + if unknown: + _fail(f"expectation has unknown fields {sorted(unknown)}") + if _integer(expectation.get("schema"), "expectation.schema") != 1: + _fail("expectation.schema must be 1") + + expected_entries = _expectation_rows( + expectation, "entry_points", ("module", "name", "stage") + ) + actual_entries = { + ("vertex", *_entry_point(vertex, "vertex")), + ("fragment", *_entry_point(fragment, "fragment")), + } + _expect_equal("entry points", actual_entries, expected_entries) + + expected_vertex_inputs = _expectation_rows( + expectation, "vertex_inputs", ("name", "location", "type") + ) + actual_vertex_inputs = _reflection_io( + vertex, "inputs", "vertex", expand_arrays=False + ) + _expect_equal("vertex inputs", actual_vertex_inputs, expected_vertex_inputs) + + vertex_outputs = _reflection_io(vertex, "outputs", "vertex", expand_arrays=False) + fragment_inputs = _reflection_io( + fragment, "inputs", "fragment", expand_arrays=False + ) + expected_interstage = _expectation_rows( + expectation, "interstage_variables", ("name", "location", "type") + ) + _expect_equal("vertex outputs", vertex_outputs, expected_interstage) + _expect_equal("fragment inputs", fragment_inputs, expected_interstage) + + expected_fragment_outputs = _expectation_rows( + expectation, "fragment_outputs", ("name", "location", "type") + ) + actual_fragment_outputs = _reflection_io( + fragment, "outputs", "fragment", expand_arrays=True + ) + _expect_equal( + "fragment outputs", actual_fragment_outputs, expected_fragment_outputs + ) + + reflections = {"vertex": vertex, "fragment": fragment} + expected_blocks = _expectation_rows( + expectation, "uniform_blocks", ("name", "set", "binding", "size"), stages=True + ) + _expect_equal( + "uniform blocks", + _resources(reflections, "ubos", include_name=True, include_size=True), + expected_blocks, + ) + + expected_samplers = _expectation_rows( + expectation, + "combined_image_samplers", + ("name", "set", "binding", "type"), + stages=True, + ) + _expect_equal( + "combined image samplers", + _resources(reflections, "textures", include_name=True, include_type=True), + expected_samplers, + ) + + push_constants = _array( + expectation.get("push_constant_ranges"), "expectation.push_constant_ranges" + ) + if push_constants: + _fail("the material profile expectation must not contain push constants") + _reject_unexpected_resource_categories(reflections) + _verify_interpolation_decorations( + expectation, + {"vertex": vertex_disassembly, "fragment": fragment_disassembly}, + ) + + +def _arguments(argv: Sequence[str]) -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Compare material SPIR-V reflection with a profile manifest-derived expectation." + ) + parser.add_argument( + "--expectation", + type=Path, + required=True, + help="JSON emitted by llshadermanifest_dump", + ) + parser.add_argument( + "--vertex-reflection", + type=Path, + required=True, + help="spirv-cross --reflect JSON for the vertex module", + ) + parser.add_argument( + "--fragment-reflection", + type=Path, + required=True, + help="spirv-cross --reflect JSON for the fragment module", + ) + parser.add_argument( + "--vertex-disassembly", + type=Path, + required=True, + help="spirv-dis text for the vertex module", + ) + parser.add_argument( + "--fragment-disassembly", + type=Path, + required=True, + help="spirv-dis text for the fragment module", + ) + return parser.parse_args(argv) + + +def main(argv: Sequence[str] | None = None) -> int: + arguments = _arguments(sys.argv[1:] if argv is None else argv) + try: + verify( + _load_json(arguments.expectation, "expectation"), + _load_json(arguments.vertex_reflection, "vertex reflection"), + _load_json(arguments.fragment_reflection, "fragment reflection"), + _load_text(arguments.vertex_disassembly, "vertex disassembly"), + _load_text(arguments.fragment_disassembly, "fragment disassembly"), + ) + except VerificationError as error: + print(f"material reflection verification failed: {error}", file=sys.stderr) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/indra/llrender/vulkan/write_shader_hashes.cmake b/indra/llrender/vulkan/write_shader_hashes.cmake new file mode 100644 index 00000000000..ed3b482d979 --- /dev/null +++ b/indra/llrender/vulkan/write_shader_hashes.cmake @@ -0,0 +1,19 @@ +# Build-local identities are deliberately generated after validation and +# reflection. Shader compiler versions are not pinned, so these are evidence +# for one build rather than source-controlled canonical hashes. + +foreach(required_variable IN ITEMS VERTEX_MODULE FRAGMENT_MODULE OUTPUT_FILE) + if (NOT DEFINED ${required_variable} OR "${${required_variable}}" STREQUAL "") + message(FATAL_ERROR "write_shader_hashes.cmake requires ${required_variable}") + endif () +endforeach () + +if (NOT EXISTS "${VERTEX_MODULE}" OR NOT EXISTS "${FRAGMENT_MODULE}") + message(FATAL_ERROR "shader modules must exist before hashing") +endif () + +file(SHA256 "${VERTEX_MODULE}" vertex_hash) +file(SHA256 "${FRAGMENT_MODULE}" fragment_hash) +file(WRITE "${OUTPUT_FILE}.tmp" + "vertex ${vertex_hash}\nfragment ${fragment_hash}\n") +file(RENAME "${OUTPUT_FILE}.tmp" "${OUTPUT_FILE}") diff --git a/indra/llwindow/llwindow.cpp b/indra/llwindow/llwindow.cpp index b6177abfc7c..75695dc03f7 100644 --- a/indra/llwindow/llwindow.cpp +++ b/indra/llwindow/llwindow.cpp @@ -125,6 +125,33 @@ LLWindow::LLWindow(LLWindowCallbacks* callbacks, bool fullscreen, U32 flags) { } +bool LLWindow::getNativeContentSize(LLCoordWindow* size) +{ + LLCoordWindow backing_size; + if (!size || !getSize(&backing_size)) + { + return false; + } + + F32 scale_x; + F32 scale_y; + getBackingScale(scale_x, scale_y); + if (scale_x <= 0.f || scale_y <= 0.f) + { + return false; + } + + size->mX = ll_round((F32)backing_size.mX / scale_x); + size->mY = ll_round((F32)backing_size.mY / scale_y); + return true; +} + +void LLWindow::getBackingScale(F32& scale_x, F32& scale_y) +{ + scale_x = getSystemUISize(); + scale_y = scale_x; +} + LLWindow::~LLWindow() { } diff --git a/indra/llwindow/llwindow.h b/indra/llwindow/llwindow.h index 327323b018d..e91415127b8 100644 --- a/indra/llwindow/llwindow.h +++ b/indra/llwindow/llwindow.h @@ -64,7 +64,7 @@ class LLWindow : public LLInstanceTracker }; enum EFlags { - // currently unused + FLAG_CREATE_HIDDEN = 1 << 0 }; public: virtual void show() = 0; @@ -80,6 +80,10 @@ class LLWindow : public LLInstanceTracker virtual bool getPosition(LLCoordScreen *position) = 0; virtual bool getSize(LLCoordScreen *size) = 0; virtual bool getSize(LLCoordWindow *size) = 0; + // Native content dimensions are logical display units. getSize() remains + // the backing-pixel size used by the renderer. + virtual bool getNativeContentSize(LLCoordWindow *size); + virtual void getBackingScale(F32& scale_x, F32& scale_y); virtual bool setPosition(LLCoordScreen position) = 0; bool setSize(LLCoordScreen size); bool setSize(LLCoordWindow size); diff --git a/indra/llwindow/llwindowmacosx-objc.h b/indra/llwindow/llwindowmacosx-objc.h index ec9afb18448..1c7d2bae3b7 100644 --- a/indra/llwindow/llwindowmacosx-objc.h +++ b/indra/llwindow/llwindowmacosx-objc.h @@ -112,10 +112,12 @@ void glSwapBuffers(void* context); CGLContextObj getCGLContextObj(GLViewRef view); unsigned long getVramSize(GLViewRef view); float getDeviceUnitSize(GLViewRef view); +void getBackingScale(GLViewRef view, float* scale_x, float* scale_y); CGRect getContentViewRect(NSWindowRef window); CGRect getBackingViewRect(NSWindowRef window, GLViewRef view); void getWindowSize(NSWindowRef window, float* size); void setWindowSize(NSWindowRef window, int width, int height); +void setWindowContentSize(NSWindowRef window, GLViewRef view, int width, int height); void getCursorPos(NSWindowRef window, float* pos); void makeWindowOrderFront(NSWindowRef window); void convertScreenToWindow(NSWindowRef window, float *coord); diff --git a/indra/llwindow/llwindowmacosx-objc.mm b/indra/llwindow/llwindowmacosx-objc.mm index d902a82a3c6..c4756e2ab25 100644 --- a/indra/llwindow/llwindowmacosx-objc.mm +++ b/indra/llwindow/llwindowmacosx-objc.mm @@ -264,6 +264,13 @@ float getDeviceUnitSize(GLViewRef view) return [(LLOpenGLView*)view convertSizeToBacking:NSMakeSize(1, 1)].width; } +void getBackingScale(GLViewRef view, float* scale_x, float* scale_y) +{ + NSSize backing_size = [(LLOpenGLView*)view convertSizeToBacking:NSMakeSize(1, 1)]; + *scale_x = backing_size.width; + *scale_y = backing_size.height; +} + CGRect getContentViewRect(NSWindowRef window) { return [[(LLNSWindow*)window contentView] bounds]; @@ -291,6 +298,13 @@ void setWindowSize(NSWindowRef window, int width, int height) [(LLNSWindow*)window setFrame:frame display:TRUE]; } +void setWindowContentSize(NSWindowRef window, GLViewRef view, int width, int height) +{ + NSSize backing_size = NSMakeSize(width, height); + NSSize content_size = [(NSOpenGLView*)view convertSizeFromBacking:backing_size]; + [(LLNSWindow*)window setContentSize:content_size]; +} + void setWindowPos(NSWindowRef window, float* pos) { NSPoint point; diff --git a/indra/llwindow/llwindowmacosx.cpp b/indra/llwindow/llwindowmacosx.cpp index 7cb681aefed..cc5cf79cf6d 100644 --- a/indra/llwindow/llwindowmacosx.cpp +++ b/indra/llwindow/llwindowmacosx.cpp @@ -211,7 +211,7 @@ LLWindowMacOSX::LLWindowMacOSX(LLWindowCallbacks* callbacks, // Create the GL context and set it up for windowed or fullscreen, as appropriate. if(createContext(x, y, width, height, 32, fullscreen, enable_vsync)) { - if(mWindow != NULL) + if (mWindow != NULL && !(flags & LLWindow::FLAG_CREATE_HIDDEN)) { makeWindowOrderFront(mWindow); } @@ -747,6 +747,10 @@ bool LLWindowMacOSX::createContext(int x, int y, int width, int height, int bits mContext = getCGLContextObj(mGLView); gGLManager.mVRAM = getVramSize(mGLView); + // The app window comes from the bundle's xib, so apply the requested + // backing-pixel size after attaching the OpenGL view. + setWindowContentSize(mWindow, mGLView, width, height); + if(!mPixelFormat) { CGLPixelFormatAttribute attribs[] = @@ -1054,6 +1058,24 @@ bool LLWindowMacOSX::getSize(LLCoordWindow *size) return (err == noErr); } +bool LLWindowMacOSX::getNativeContentSize(LLCoordWindow* size) +{ + if (!size || !mWindow) + { + return false; + } + + CGSize native_size = getContentViewRect(mWindow).size; + size->mX = ll_round(native_size.width); + size->mY = ll_round(native_size.height); + return true; +} + +void LLWindowMacOSX::getBackingScale(F32& scale_x, F32& scale_y) +{ + ::getBackingScale(mGLView, &scale_x, &scale_y); +} + bool LLWindowMacOSX::setPosition(const LLCoordScreen position) { if(mWindow) @@ -1082,8 +1104,7 @@ bool LLWindowMacOSX::setSizeImpl(const LLCoordWindow size) { if (mWindow) { - const int titlePadding = 22; - setWindowSize(mWindow, size.mX, size.mY + titlePadding); + setWindowContentSize(mWindow, mGLView, size.mX, size.mY); return true; } diff --git a/indra/llwindow/llwindowmacosx.h b/indra/llwindow/llwindowmacosx.h index 9534eadf8e6..b126d541de9 100644 --- a/indra/llwindow/llwindowmacosx.h +++ b/indra/llwindow/llwindowmacosx.h @@ -52,6 +52,8 @@ class LLWindowMacOSX : public LLWindow bool getPosition(LLCoordScreen *position) override; bool getSize(LLCoordScreen *size) override; bool getSize(LLCoordWindow *size) override; + bool getNativeContentSize(LLCoordWindow *size) override; + void getBackingScale(F32& scale_x, F32& scale_y) override; bool setPosition(LLCoordScreen position) override; bool setSizeImpl(LLCoordScreen size) override; bool setSizeImpl(LLCoordWindow size) override; diff --git a/indra/newview/CMakeLists.txt b/indra/newview/CMakeLists.txt index 7aa255c5dac..e16d2b87e95 100644 --- a/indra/newview/CMakeLists.txt +++ b/indra/newview/CMakeLists.txt @@ -106,6 +106,7 @@ set(viewer_SOURCE_FILES llappearancemgr.cpp llappviewer.cpp llappviewerlistener.cpp + llcontractparityargs.cpp llattachmentsmgr.cpp llaudiosourcevo.cpp llautoreplace.cpp @@ -155,6 +156,8 @@ set(viewer_SOURCE_FILES lldonotdisturbnotificationstorage.cpp lldndbutton.cpp lldrawable.cpp + lldrawinfo.cpp + lldrawinfotranslator.cpp lldrawpool.cpp lldrawpoolalpha.cpp lldrawpoolavatar.cpp @@ -402,6 +405,8 @@ set(viewer_SOURCE_FILES llmarketplacenotifications.cpp llmaterialeditor.cpp llmaterialmgr.cpp + llmaterialparity.cpp + lltextureuploadparity.cpp llmediactrl.cpp llmediadataclient.cpp llmenuoptionpathfindingrebakenavmesh.cpp @@ -790,6 +795,7 @@ set(viewer_HEADER_FILES llappearancemgr.h llappviewer.h llappviewerlistener.h + llcontractparityargs.h llattachmentsmgr.h llaudiosourcevo.h llautoreplace.h @@ -800,6 +806,9 @@ set(viewer_HEADER_FILES llavatarpropertiesprocessor.h llavatarrenderinfoaccountant.h llavatarrendernotifier.h + llbenchmarkappearance.h + llbenchmarkdisplay.h + llcachemigration.h llblockedlistitem.h llblocklist.h llbox.h @@ -839,6 +848,7 @@ set(viewer_HEADER_FILES lldonotdisturbnotificationstorage.h lldndbutton.h lldrawable.h + lldrawinfotranslator.h lldrawpool.h lldrawpoolalpha.h lldrawpoolavatar.h @@ -1087,6 +1097,8 @@ set(viewer_HEADER_FILES llmarketplacenotifications.h llmaterialeditor.h llmaterialmgr.h + llmaterialparity.h + lltextureuploadparity.h llmediactrl.h llmediadataclient.h llmenuoptionpathfindingrebakenavmesh.h @@ -1784,7 +1796,10 @@ set_source_files_properties(${viewer_CHARACTER_FILES} list(APPEND viewer_SOURCE_FILES ${viewer_CHARACTER_FILES}) set(viewer_MISC_FILES + clean_vulkan_material_install.cmake viewer_manifest.py + vulkan_material_artifact_delivery.py + tests/test_vulkan_material_artifact_delivery.py ) set_source_files_properties(${viewer_MISC_FILES} @@ -1792,6 +1807,45 @@ set_source_files_properties(${viewer_MISC_FILES} list(APPEND viewer_SOURCE_FILES ${viewer_MISC_FILES}) +set(VULKAN_MATERIAL_ARTIFACT_MANIFEST_ARGS) +set(VULKAN_MATERIAL_ARTIFACT_DEPENDENCIES) +set(VULKAN_MATERIAL_ARTIFACT_STATE_FILE + "${CMAKE_CURRENT_BINARY_DIR}/vulkan_material_artifact_delivery.state") +file(GENERATE + OUTPUT "${VULKAN_MATERIAL_ARTIFACT_STATE_FILE}" + CONTENT "${LL_VULKAN_TONEMAP_TEST}\n") +set(VULKAN_MATERIAL_ARTIFACT_FILE_DEPENDENCIES + "${VULKAN_MATERIAL_ARTIFACT_STATE_FILE}") +if (LL_VULKAN_TONEMAP_TEST) + if (NOT LL_VULKAN_MATERIAL_PRODUCTION_ARTIFACT_DIR OR + NOT LL_VULKAN_MATERIAL_PRODUCTION_VERTEX_SPV OR + NOT LL_VULKAN_MATERIAL_PRODUCTION_FRAGMENT_SPV OR + NOT LL_VULKAN_MATERIAL_PRODUCTION_VERIFIED_STAMP OR + NOT TARGET llvulkanmaterialproduction_shaders) + message(FATAL_ERROR + "The Vulkan material artifact directory and validation target must come from llrender") + endif () + list(APPEND VULKAN_MATERIAL_ARTIFACT_MANIFEST_ARGS + "--vulkan_material_artifact_dir=${LL_VULKAN_MATERIAL_PRODUCTION_ARTIFACT_DIR}") + list(APPEND VULKAN_MATERIAL_ARTIFACT_DEPENDENCIES + llvulkanmaterialproduction_shaders) + list(APPEND VULKAN_MATERIAL_ARTIFACT_FILE_DEPENDENCIES + "${LL_VULKAN_MATERIAL_PRODUCTION_VERTEX_SPV}" + "${LL_VULKAN_MATERIAL_PRODUCTION_FRAGMENT_SPV}" + "${LL_VULKAN_MATERIAL_PRODUCTION_VERIFIED_STAMP}") + + add_test( + NAME VULKAN_MATERIAL_ARTIFACT_DELIVERY + COMMAND "${PYTHON_EXECUTABLE}" -B + "${CMAKE_CURRENT_SOURCE_DIR}/tests/test_vulkan_material_artifact_delivery.py") + add_test( + NAME VULKAN_MATERIAL_ARTIFACT_MANIFEST_ARGUMENT + COMMAND "${PYTHON_EXECUTABLE}" -B + "${CMAKE_CURRENT_SOURCE_DIR}/viewer_manifest.py" + --help + "--vulkan_material_artifact_dir=${LL_VULKAN_MATERIAL_PRODUCTION_ARTIFACT_DIR}") +endif () + if (WINDOWS) file(GLOB viewer_INSTALLER_FILES installers/windows/*.nsi) @@ -1805,12 +1859,34 @@ endif (WINDOWS) list(APPEND viewer_SOURCE_FILES ${viewer_HEADER_FILES}) +if (LL_RENDER_BENCHMARK) + set_property(SOURCE + llappviewer.cpp + llstatslistener.cpp + llviewerdisplay.cpp + llviewerwindow.cpp + llviewerstats.cpp + APPEND PROPERTY COMPILE_DEFINITIONS LL_RENDER_BENCHMARK=1) + set_property(SOURCE + llappviewer.cpp + llstatslistener.cpp + llviewerdisplay.cpp + llviewerwindow.cpp + PROPERTY SKIP_PRECOMPILE_HEADERS ON) +endif() + add_executable(${VIEWER_BINARY_NAME} WIN32 MACOSX_BUNDLE ${viewer_SOURCE_FILES} ) +if (VULKAN_MATERIAL_ARTIFACT_DEPENDENCIES) + add_dependencies( + ${VIEWER_BINARY_NAME} + ${VULKAN_MATERIAL_ARTIFACT_DEPENDENCIES}) +endif () + target_include_directories(${VIEWER_BINARY_NAME} PUBLIC ${CMAKE_CURRENT_BINARY_DIR}) # add package files @@ -2197,9 +2273,13 @@ if (LINUX) --source=${CMAKE_CURRENT_SOURCE_DIR} --touch=${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/.${product}.touched --versionfile=${CMAKE_CURRENT_BINARY_DIR}/viewer_version.txt + ${VULKAN_MATERIAL_ARTIFACT_MANIFEST_ARGS} DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/viewer_manifest.py + ${CMAKE_CURRENT_SOURCE_DIR}/vulkan_material_artifact_delivery.py ${COPY_INPUT_DEPENDENCIES} + ${VULKAN_MATERIAL_ARTIFACT_DEPENDENCIES} + ${VULKAN_MATERIAL_ARTIFACT_FILE_DEPENDENCIES} ) if (PACKAGE) @@ -2225,9 +2305,13 @@ if (LINUX) --grid=${GRID} --source=${CMAKE_CURRENT_SOURCE_DIR} --versionfile=${CMAKE_CURRENT_BINARY_DIR}/viewer_version.txt + ${VULKAN_MATERIAL_ARTIFACT_MANIFEST_ARGS} DEPENDS ${CMAKE_CURRENT_SOURCE_DIR}/viewer_manifest.py + ${CMAKE_CURRENT_SOURCE_DIR}/vulkan_material_artifact_delivery.py ${COPY_INPUT_DEPENDENCIES} + ${VULKAN_MATERIAL_ARTIFACT_DEPENDENCIES} + ${VULKAN_MATERIAL_ARTIFACT_FILE_DEPENDENCIES} COMMENT "Performing viewer_manifest copy" ) @@ -2280,10 +2364,7 @@ if (DARWIN) "${VIEWER_APP_BUNDLE}/Contents/Info.plist" ) - add_custom_command( - TARGET ${VIEWER_BINARY_NAME} POST_BUILD - COMMAND ${PYTHON_EXECUTABLE} - ARGS + set(MAC_VIEWER_MANIFEST_COPY_ARGS ${CMAKE_CURRENT_SOURCE_DIR}/viewer_manifest.py --actions=copy --arch=${ARCH} @@ -2301,8 +2382,33 @@ if (DARWIN) --grid=${GRID} --source=${CMAKE_CURRENT_SOURCE_DIR} --versionfile=${CMAKE_CURRENT_BINARY_DIR}/viewer_version.txt + ${VULKAN_MATERIAL_ARTIFACT_MANIFEST_ARGS}) + + add_custom_command( + TARGET ${VIEWER_BINARY_NAME} POST_BUILD + COMMAND ${PYTHON_EXECUTABLE} + ARGS ${MAC_VIEWER_MANIFEST_COPY_ARGS} ) + set(MAC_VULKAN_MATERIAL_ARTIFACT_COPY_STAMP + "${CMAKE_CURRENT_BINARY_DIR}/${CMAKE_CFG_INTDIR}/.vulkan_material_artifacts.touched") + add_custom_command( + OUTPUT "${MAC_VULKAN_MATERIAL_ARTIFACT_COPY_STAMP}" + COMMAND ${PYTHON_EXECUTABLE} + ARGS ${MAC_VIEWER_MANIFEST_COPY_ARGS} + COMMAND "${CMAKE_COMMAND}" -E touch + "${MAC_VULKAN_MATERIAL_ARTIFACT_COPY_STAMP}" + DEPENDS + ${VIEWER_BINARY_NAME} + ${CMAKE_CURRENT_SOURCE_DIR}/viewer_manifest.py + ${CMAKE_CURRENT_SOURCE_DIR}/vulkan_material_artifact_delivery.py + ${VULKAN_MATERIAL_ARTIFACT_DEPENDENCIES} + ${VULKAN_MATERIAL_ARTIFACT_FILE_DEPENDENCIES} + COMMENT "Refreshing packaged Vulkan material artifacts" + VERBATIM) + add_custom_target(copy_m_vulkan_material_artifacts ALL + DEPENDS "${MAC_VULKAN_MATERIAL_ARTIFACT_COPY_STAMP}") + add_dependencies(${VIEWER_BINARY_NAME} SLPlugin media_plugin_libvlc media_plugin_cef) if (ENABLE_SIGNING) @@ -2313,6 +2419,7 @@ if (DARWIN) if (PACKAGE) add_custom_target(llpackage ALL DEPENDS ${VIEWER_BINARY_NAME}) + add_dependencies(llpackage copy_m_vulkan_material_artifacts) add_custom_command( TARGET llpackage POST_BUILD @@ -2337,6 +2444,7 @@ if (DARWIN) --touch=${CMAKE_CURRENT_BINARY_DIR}/$,$,>/.${product}.bat --versionfile=${CMAKE_CURRENT_BINARY_DIR}/viewer_version.txt ${SIGNING_SETTING} + ${VULKAN_MATERIAL_ARTIFACT_MANIFEST_ARGS} ) endif (PACKAGE) endif (DARWIN) @@ -2476,6 +2584,43 @@ if (LL_TESTS) "${test_libs}" ) + LL_ADD_INTEGRATION_TEST(llbenchmarkdisplay + "" + "${test_libs}" + ) + + LL_ADD_INTEGRATION_TEST(llbenchmarkappearance + "" + "${test_libs}" + ) + + set(lldrawinfotranslator_test_sources + lldrawinfo.cpp + lldrawinfotranslator.cpp + tests/lldrawinfotranslator_texture_stub.cpp + ) + LL_ADD_INTEGRATION_TEST(lldrawinfotranslator + "${lldrawinfotranslator_test_sources}" + "${test_libs};llrender;llrendercontract;OpenGL::GL" + ) + target_include_directories(INTEGRATION_TEST_lldrawinfotranslator PRIVATE ${CMAKE_CURRENT_SOURCE_DIR}) + if (LINUX) + target_include_directories(INTEGRATION_TEST_lldrawinfotranslator PRIVATE + ${X11_X11_INCLUDE_PATH} + ${X11_Xlib_INCLUDE_PATH} + ) + endif () + + LL_ADD_INTEGRATION_TEST(llcachemigration + "" + "${test_libs}" + ) + + LL_ADD_INTEGRATION_TEST(llcontractparityargs + llcontractparityargs.cpp + "${test_libs}" + ) + LL_ADD_INTEGRATION_TEST(llsechandler_basic llsechandler_basic.cpp "${test_libs}" diff --git a/indra/newview/SecondLife.xib b/indra/newview/SecondLife.xib index 781a3906731..098f647625e 100644 --- a/indra/newview/SecondLife.xib +++ b/indra/newview/SecondLife.xib @@ -137,7 +137,7 @@ - + diff --git a/indra/newview/ViewerInstall.cmake b/indra/newview/ViewerInstall.cmake index ac2247c8156..b09e41ce020 100644 --- a/indra/newview/ViewerInstall.cmake +++ b/indra/newview/ViewerInstall.cmake @@ -7,6 +7,21 @@ install(DIRECTORY skins app_settings linux_tools PATTERN ".svn" EXCLUDE ) +set(VULKAN_MATERIAL_ARTIFACT_INSTALL_DIR + "${APP_SHARE_DIR}/app_settings/shaders/vulkan/legacy_normspec") +install(CODE + "set(VULKAN_MATERIAL_INSTALL_DESTINATION \"\$ENV{DESTDIR}${VULKAN_MATERIAL_ARTIFACT_INSTALL_DIR}\")\ninclude(\"${CMAKE_CURRENT_SOURCE_DIR}/clean_vulkan_material_install.cmake\")") +if (LL_VULKAN_TONEMAP_TEST) + install(FILES + "${LL_VULKAN_MATERIAL_PRODUCTION_ARTIFACT_DIR}/material.production.vert.spv" + DESTINATION "${VULKAN_MATERIAL_ARTIFACT_INSTALL_DIR}" + RENAME production.vert.spv) + install(FILES + "${LL_VULKAN_MATERIAL_PRODUCTION_ARTIFACT_DIR}/material.production.frag.spv" + DESTINATION "${VULKAN_MATERIAL_ARTIFACT_INSTALL_DIR}" + RENAME production.frag.spv) +endif () + find_file(IS_ARTWORK_PRESENT NAMES have_artwork_bundle.marker PATHS ${VIEWER_DIR}/newview/res) @@ -19,7 +34,7 @@ else (IS_ARTWORK_PRESENT) message(STATUS "WARNING: Artwork is not present, and will not be installed") endif (IS_ARTWORK_PRESENT) -install(FILES featuretable_linux.txt featuretable_solaris.txt +install(FILES featuretable_linux.txt DESTINATION ${APP_SHARE_DIR} ) diff --git a/indra/newview/app_settings/cmd_line.xml b/indra/newview/app_settings/cmd_line.xml index bf7f9bab1f6..bb5414ff33b 100644 --- a/indra/newview/app_settings/cmd_line.xml +++ b/indra/newview/app_settings/cmd_line.xml @@ -393,6 +393,60 @@ CmdLineLoginLocation + materialparity + + desc + Run the fixed-input material contract parity check and exit. + map-to + RenderMaterialContractParityTest + + + materialartifact + + desc + Write the successful material parity reference to this new artifact file. + count + 1 + map-to + RenderMaterialArtifactPath + + + textureuploadparity + + desc + Run the fixed-input texture upload contract parity check and exit. + map-to + RenderTextureUploadContractParityTest + + + textureuploadartifact + + desc + Write the successful texture upload parity reference to this new artifact file. + count + 1 + map-to + RenderTextureUploadArtifactPath + + + tonemapparity + + desc + Run the fixed-input legacy/contract tonemap parity check and exit. + map-to + RenderTonemapContractParityTest + + + tonemapartifact + + desc + Write the successful tonemap parity reference to this new artifact file. + count + 1 + map-to + RenderTonemapArtifactPath + + url desc diff --git a/indra/newview/app_settings/settings.xml b/indra/newview/app_settings/settings.xml index ad307f96488..a0c0849ad88 100644 --- a/indra/newview/app_settings/settings.xml +++ b/indra/newview/app_settings/settings.xml @@ -10205,6 +10205,50 @@ Value 00000000-0000-0000-0000-000000000000 + RenderMaterialContractParityTest + + Comment + Run the account-free fixed-input material contract parity check and exit + Persist + 0 + Type + Boolean + Value + 0 + + RenderMaterialArtifactPath + + Comment + New output path for an account-free material reference artifact + Persist + 0 + Type + String + Value + + + RenderTextureUploadContractParityTest + + Comment + Run the account-free fixed-input texture upload contract parity check and exit + Persist + 0 + Type + Boolean + Value + 0 + + RenderTextureUploadArtifactPath + + Comment + New output path for an account-free texture upload reference artifact + Persist + 0 + Type + String + Value + + RenderCASSharpness Comment @@ -10227,6 +10271,28 @@ Value 0.7 + RenderTonemapContractParityTest + + Comment + Run the account-free fixed-input tonemap contract parity check and exit + Persist + 0 + Type + Boolean + Value + 0 + + RenderTonemapArtifactPath + + Comment + New output path for an account-free tonemap reference artifact + Persist + 0 + Type + String + Value + + RenderTonemapType Comment @@ -10238,6 +10304,17 @@ Value 1 + RenderUseTonemapContract + + Comment + Use the developer tonemap render-contract path with preflight fallback to legacy rendering + Persist + 0 + Type + Boolean + Value + 0 + RenderHighPrecisionPostProcess Comment @@ -12712,6 +12789,17 @@ Value 1.0 + RenderBenchmarkUIScale + + Comment + Effective UI scale required by a benchmark session; zero disables benchmark normalization + Persist + 0 + Type + F32 + Value + 0.0 + UIScrollbarSize Comment diff --git a/indra/newview/app_settings/shaders/class1/deferred/globalF.glsl b/indra/newview/app_settings/shaders/class1/deferred/globalF.glsl index 2ed4ba3163e..1cfdeb4ffd0 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/globalF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/globalF.glsl @@ -28,9 +28,11 @@ // DO NOT declare sampler uniforms here as OS X doesn't compile // them out +#ifndef LL_VULKAN_SHADER uniform float mirror_flag; uniform vec4 clipPlane; uniform float clipSign; +#endif void mirrorClip(vec3 pos) { diff --git a/indra/newview/app_settings/shaders/class1/deferred/materialV.glsl b/indra/newview/app_settings/shaders/class1/deferred/materialV.glsl index b6528dfcf80..8a6b39a9848 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/materialV.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/materialV.glsl @@ -28,6 +28,7 @@ #define DIFFUSE_ALPHA_MODE_MASK 2 #define DIFFUSE_ALPHA_MODE_EMISSIVE 3 +#ifndef LL_VULKAN_SHADER uniform mat4 modelview_matrix; uniform mat4 projection_matrix; uniform mat4 modelview_projection_matrix; @@ -68,6 +69,7 @@ out vec2 vary_texcoord2; out vec4 vertex_color; out vec2 vary_texcoord0; +#endif void main() { @@ -128,4 +130,3 @@ void main() vary_position = (modelview_matrix*vec4(position.xyz, 1.0)).xyz; #endif } - diff --git a/indra/newview/app_settings/shaders/class1/deferred/postDeferredNoTCV.glsl b/indra/newview/app_settings/shaders/class1/deferred/postDeferredNoTCV.glsl index 87f28621ae9..b5ac7644e07 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/postDeferredNoTCV.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/postDeferredNoTCV.glsl @@ -23,11 +23,13 @@ * $/LicenseInfo$ */ +#ifndef LL_VULKAN_SHADER in vec3 position; out vec2 vary_fragcoord; uniform vec2 screen_res; +#endif void main() { diff --git a/indra/newview/app_settings/shaders/class1/deferred/postDeferredTonemap.glsl b/indra/newview/app_settings/shaders/class1/deferred/postDeferredTonemap.glsl index b1218d61aff..8e3708ce664 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/postDeferredTonemap.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/postDeferredTonemap.glsl @@ -25,6 +25,7 @@ /*[EXTRA_CODE_HERE]*/ +#ifndef LL_VULKAN_SHADER out vec4 frag_color; uniform sampler2D diffuseRect; @@ -34,6 +35,7 @@ in vec2 vary_fragcoord; #ifdef GAMMA_CORRECT uniform float gamma; #endif +#endif vec3 linear_to_srgb(vec3 cl); vec3 toneMap(vec3 color); @@ -75,4 +77,3 @@ void main() //debugExposure(diff.rgb); frag_color = diff; } - diff --git a/indra/newview/app_settings/shaders/class1/deferred/tonemapUtilF.glsl b/indra/newview/app_settings/shaders/class1/deferred/tonemapUtilF.glsl index 774ccb6baff..69b5c95a18e 100644 --- a/indra/newview/app_settings/shaders/class1/deferred/tonemapUtilF.glsl +++ b/indra/newview/app_settings/shaders/class1/deferred/tonemapUtilF.glsl @@ -25,9 +25,11 @@ /*[EXTRA_CODE_HERE]*/ +#ifndef LL_VULKAN_SHADER uniform sampler2D exposureMap; uniform vec2 screen_res; in vec2 vary_fragcoord; +#endif //=============================================================== // tone mapping taken from Khronos sample implementation @@ -113,9 +115,11 @@ vec3 PBRNeutralToneMapping( vec3 color ) return mix(color, newPeak * vec3(1, 1, 1), g); } +#ifndef LL_VULKAN_SHADER uniform float exposure; uniform float tonemap_mix; uniform int tonemap_type; +#endif vec3 toneMap(vec3 color) diff --git a/indra/newview/app_settings/shaders/class1/interface/copyF.glsl b/indra/newview/app_settings/shaders/class1/interface/copyF.glsl index 094d147e863..2c347db7e14 100644 --- a/indra/newview/app_settings/shaders/class1/interface/copyF.glsl +++ b/indra/newview/app_settings/shaders/class1/interface/copyF.glsl @@ -23,6 +23,7 @@ * $/LicenseInfo$ */ +#ifndef LL_VULKAN_SHADER in vec2 tc; #if defined(COPY_DEPTH) @@ -32,6 +33,7 @@ uniform sampler2D depthMap; uniform sampler2D diffuseMap; out vec4 frag_color; +#endif void main() { @@ -40,4 +42,3 @@ void main() gl_FragDepth = texture(depthMap, tc).r; #endif } - diff --git a/indra/newview/app_settings/shaders/class1/interface/copyV.glsl b/indra/newview/app_settings/shaders/class1/interface/copyV.glsl index e8cc189c61f..0e2c857454b 100644 --- a/indra/newview/app_settings/shaders/class1/interface/copyV.glsl +++ b/indra/newview/app_settings/shaders/class1/interface/copyV.glsl @@ -24,8 +24,10 @@ */ +#ifndef LL_VULKAN_SHADER in vec3 position; out vec2 tc; +#endif void main() { diff --git a/indra/newview/app_settings/shaders/class3/deferred/materialF.glsl b/indra/newview/app_settings/shaders/class3/deferred/materialF.glsl index 1738cbf9a22..617dbcf0388 100644 --- a/indra/newview/app_settings/shaders/class3/deferred/materialF.glsl +++ b/indra/newview/app_settings/shaders/class3/deferred/materialF.glsl @@ -34,9 +34,11 @@ #define DIFFUSE_ALPHA_MODE_MASK 2 #define DIFFUSE_ALPHA_MODE_EMISSIVE 3 +#ifndef LL_VULKAN_SHADER uniform float emissive_brightness; // fullbright flag, 1.0 == fullbright, 0.0 otherwise uniform int sun_up_factor; uniform int classic_mode; +#endif vec4 applySkyAndWaterFog(vec3 pos, vec3 additive, vec3 atten, vec4 color); vec3 scaleSoftClipFragLinear(vec3 l); @@ -46,10 +48,12 @@ void calcHalfVectors(vec3 lv, vec3 n, vec3 v, out vec3 h, out vec3 l, out float vec3 srgb_to_linear(vec3 cs); vec3 linear_to_srgb(vec3 cs); +#ifndef LL_VULKAN_SHADER uniform mat4 modelview_matrix; uniform mat3 normal_matrix; in vec3 vary_position; +#endif void mirrorClip(vec3 pos); vec4 encodeNormal(vec3 n, float env, float gbuffer_flag); @@ -185,9 +189,12 @@ vec3 calcPointLightOrSpotLight(vec3 light_col, vec3 npos, vec3 diffuse, vec4 spe } #else +#ifndef LL_VULKAN_SHADER out vec4 frag_data[4]; #endif +#endif +#ifndef LL_VULKAN_SHADER uniform sampler2D diffuseMap; //always in sRGB space #ifdef HAS_NORMAL_MAP @@ -218,6 +225,7 @@ in vec3 vary_normal; in vec4 vertex_color; in vec2 vary_texcoord0; +#endif // get the transformed normal and apply glossiness component from normal map vec3 getNormal(inout float glossiness) diff --git a/indra/newview/clean_vulkan_material_install.cmake b/indra/newview/clean_vulkan_material_install.cmake new file mode 100644 index 00000000000..5effd43cd23 --- /dev/null +++ b/indra/newview/clean_vulkan_material_install.cmake @@ -0,0 +1,40 @@ +if (NOT DEFINED VULKAN_MATERIAL_INSTALL_DESTINATION OR + VULKAN_MATERIAL_INSTALL_DESTINATION STREQUAL "") + message(FATAL_ERROR "VULKAN_MATERIAL_INSTALL_DESTINATION is required") +endif () + +# Walk existing parents without following them. Recursive removal is safe only +# when the dedicated destination sits below real install-tree directories. +get_filename_component( + _vulkan_material_install_parent + "${VULKAN_MATERIAL_INSTALL_DESTINATION}" + DIRECTORY) +while (TRUE) + if (IS_SYMLINK "${_vulkan_material_install_parent}") + message(FATAL_ERROR + "Refusing Vulkan material cleanup through symlink: ${_vulkan_material_install_parent}") + endif () + get_filename_component( + _vulkan_material_install_next_parent + "${_vulkan_material_install_parent}" + DIRECTORY) + if (_vulkan_material_install_next_parent STREQUAL + _vulkan_material_install_parent) + break() + endif () + set(_vulkan_material_install_parent + "${_vulkan_material_install_next_parent}") +endwhile () + +if (IS_SYMLINK "${VULKAN_MATERIAL_INSTALL_DESTINATION}") + file(REMOVE "${VULKAN_MATERIAL_INSTALL_DESTINATION}") +elseif (EXISTS "${VULKAN_MATERIAL_INSTALL_DESTINATION}") + if (IS_DIRECTORY "${VULKAN_MATERIAL_INSTALL_DESTINATION}") + file(REMOVE_RECURSE "${VULKAN_MATERIAL_INSTALL_DESTINATION}") + else () + file(REMOVE "${VULKAN_MATERIAL_INSTALL_DESTINATION}") + endif () +endif () + +unset(_vulkan_material_install_next_parent) +unset(_vulkan_material_install_parent) diff --git a/indra/newview/llappviewer.cpp b/indra/newview/llappviewer.cpp index 6fef9e25125..8029d27c40c 100644 --- a/indra/newview/llappviewer.cpp +++ b/indra/newview/llappviewer.cpp @@ -27,6 +27,7 @@ #include "llviewerprecompiledheaders.h" #include "llappviewer.h" +#include "llcachemigration.h" // Viewer includes #include "llversioninfo.h" @@ -155,6 +156,8 @@ #include #include #include +#include +#include #if LL_WINDOWS # include // For _SH_DENYWR in processMarkerFiles @@ -795,6 +798,24 @@ bool LLAppViewer::init() return false; } + const bool tonemap_parity = gSavedSettings.getBOOL("RenderTonemapContractParityTest"); + const bool material_parity = gSavedSettings.getBOOL("RenderMaterialContractParityTest"); + const bool texture_upload_parity = gSavedSettings.getBOOL("RenderTextureUploadContractParityTest"); + const char* isolated_user_dir = std::getenv("SECONDLIFE_USER_DIR"); + if ((tonemap_parity || material_parity || texture_upload_parity) + && (!isolated_user_dir || isolated_user_dir[0] == '\0')) + { + std::fputs( + tonemap_parity + ? "TONEMAP_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n" + : material_parity + ? "MATERIAL_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n" + : "TEXTURE_UPLOAD_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n", + stderr); + std::fflush(stderr); + std::_Exit(EXIT_FAILURE); + } + LL_INFOS("InitInfo") << "Configuration initialized." << LL_ENDL ; //set the max heap size. @@ -1474,6 +1495,11 @@ bool LLAppViewer::doFrame() { LLPerfStats::RecordSceneTime T (LLPerfStats::StatType_t::RENDER_IDLE); +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_idle_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_IDLE)); + (void)renderer_idle_timer; +#endif LL_PROFILE_ZONE_NAMED_CATEGORY_APP("df idle"); idle(); } @@ -3464,6 +3490,20 @@ bool LLAppViewer::initWindow() gSavedSettings.setBOOL("RenderInitError", true); gSavedSettings.saveToFile( gSavedSettings.getString("ClientSettingsFile"), true ); + if (gSavedSettings.getBOOL("RenderMaterialContractParityTest")) + { + // Pin the diagnostic shader permutation without changing the isolated profile. + gSavedSettings.getControl("RenderShaderCacheEnabled")->setValue(false, false); + gSavedSettings.getControl("RenderEnableEmissiveBuffer")->setValue(false, false); + gSavedSettings.getControl("RenderShadowDetail")->setValue(LLSD::Integer(0), false); + gSavedSettings.getControl("RenderHDREnabled")->setValue(true, false); + } + + if (gSavedSettings.getBOOL("RenderTextureUploadContractParityTest")) + { + gSavedSettings.getControl("RenderShaderCacheEnabled")->setValue(false, false); + } + gPipeline.init(); LL_INFOS("AppInit") << "gPipeline Initialized" << LL_ENDL; @@ -3473,6 +3513,30 @@ bool LLAppViewer::initWindow() gSavedSettings.setBOOL("RenderInitError", false); gSavedSettings.saveToFile( gSavedSettings.getString("ClientSettingsFile"), true ); + if (gSavedSettings.getBOOL("RenderTonemapContractParityTest")) + { + const bool success = gPipeline.runTonemapContractParity(); + removeMarkerFiles(); + std::fflush(nullptr); + std::_Exit(success ? EXIT_SUCCESS : EXIT_FAILURE); + } + + if (gSavedSettings.getBOOL("RenderMaterialContractParityTest")) + { + const bool success = gPipeline.runMaterialContractParity(); + removeMarkerFiles(); + std::fflush(nullptr); + std::_Exit(success ? EXIT_SUCCESS : EXIT_FAILURE); + } + + if (gSavedSettings.getBOOL("RenderTextureUploadContractParityTest")) + { + const bool success = gPipeline.runTextureUploadContractParity(); + removeMarkerFiles(); + std::fflush(nullptr); + std::_Exit(success ? EXIT_SUCCESS : EXIT_FAILURE); + } + // If we have a startup crash, it's usually near GL initialization, so simulate that. if (gCrashOnStartup) { @@ -4633,7 +4697,8 @@ void LLAppViewer::migrateCacheDirectory() std::string old_cache_dir = gDirUtilp->add(gDirUtilp->getOSUserAppDir(), "cache"); std::string new_cache_dir = gDirUtilp->getCacheDir(true); - if (gDirUtilp->fileExists(old_cache_dir)) + if (gDirUtilp->fileExists(old_cache_dir) + && LLCacheMigration::required(old_cache_dir, new_cache_dir)) { LL_INFOS() << "Migrating cache from " << old_cache_dir << " to " << new_cache_dir << LL_ENDL; diff --git a/indra/newview/llappviewerlinux.cpp b/indra/newview/llappviewerlinux.cpp index 2fd0eebd448..cbc3dfed937 100644 --- a/indra/newview/llappviewerlinux.cpp +++ b/indra/newview/llappviewerlinux.cpp @@ -29,6 +29,7 @@ #include "llappviewerlinux.h" #include "llcommandlineparser.h" +#include "llcontractparityargs.h" #include "lldiriterator.h" #include "llurldispatcher.h" // SLURL from other app instance @@ -38,7 +39,13 @@ #include "llfindlocale.h" #include "llversioninfo.h" +#include +#include #include +#include +#include + +#include #define SDL_MAIN_USE_CALLBACKS #include @@ -77,6 +84,30 @@ namespace char **gArgV = NULL; LLAppViewerLinux* gViewerAppPtr = NULL; void (*gOldTerminateHandler)() = NULL; + + bool prepareContractParityUserDir(const char* path) + { + std::error_code error; + const std::filesystem::path root(path); + std::filesystem::create_directories(root, error); + if (error || !std::filesystem::is_directory(root, error) || error) + { + return false; + } + + std::string probe = (root / ".render-contract-isolation.XXXXXX").string(); + std::vector probe_name(probe.begin(), probe.end()); + probe_name.push_back('\0'); + const int descriptor = mkstemp(probe_name.data()); + if (descriptor == -1) + { + return false; + } + + const bool closed = close(descriptor) == 0; + const bool removed = unlink(probe_name.data()) == 0; + return closed && removed; + } } // Initialize static members @@ -194,6 +225,35 @@ SDL_AppResult SDL_AppInit(void **appstate, int argc, char **argv) LL_PROFILER_FRAME_END; LL_PROFILER_SET_THREAD_NAME("App"); + const LLContractParitySelection parity = getRawContractParitySelection(argc, argv); + const char* isolated_user_dir = std::getenv("SECONDLIFE_USER_DIR"); + if ((parity.mTonemap || parity.mMaterial || parity.mTextureUpload) + && (!isolated_user_dir || isolated_user_dir[0] == '\0')) + { + std::fputs( + parity.mTonemap + ? "TONEMAP_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n" + : parity.mMaterial + ? "MATERIAL_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n" + : "TEXTURE_UPLOAD_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n", + stderr); + std::fflush(stderr); + return SDL_APP_FAILURE; + } + if ((parity.mTonemap || parity.mMaterial || parity.mTextureUpload) + && !prepareContractParityUserDir(isolated_user_dir)) + { + std::fputs( + parity.mTonemap + ? "TONEMAP_CONTRACT_PARITY result=fail reason=invalid_SECONDLIFE_USER_DIR\n" + : parity.mMaterial + ? "MATERIAL_CONTRACT_PARITY result=fail reason=invalid_SECONDLIFE_USER_DIR\n" + : "TEXTURE_UPLOAD_CONTRACT_PARITY result=fail reason=invalid_SECONDLIFE_USER_DIR\n", + stderr); + std::fflush(stderr); + return SDL_APP_FAILURE; + } + gSDLMainHandled = true; gArgC = argc; @@ -266,7 +326,7 @@ SDL_AppResult SDL_AppEvent(void *appstate, SDL_Event *event) void SDL_AppQuit(void *appstate, SDL_AppResult result) { - if (!LLApp::isError()) + if (gViewerAppPtr && !LLApp::isError()) { // // We don't want to do cleanup here if the error handler got called - diff --git a/indra/newview/llappviewermacosx.cpp b/indra/newview/llappviewermacosx.cpp index c188bb459f3..60a7339b6c9 100644 --- a/indra/newview/llappviewermacosx.cpp +++ b/indra/newview/llappviewermacosx.cpp @@ -37,6 +37,7 @@ #include "llappviewermacosx.h" #include "llappviewermacosx-for-objc.h" +#include "llcontractparityargs.h" #include "llwindowmacosx-objc.h" #include "llcommandlineparser.h" #include "llsdserialize.h" @@ -56,6 +57,8 @@ #include #include #include +#include +#include #include "lldir.h" #include "lldiriterator.h" @@ -71,6 +74,7 @@ namespace char** gArgV; LLAppViewerMacOSX* gViewerAppPtr = NULL; std::string gHandleSLURL; + } void constructViewer() @@ -275,6 +279,22 @@ int main( int argc, char **argv ) LL_PROFILER_FRAME_END; LL_PROFILER_SET_THREAD_NAME("App"); + const LLContractParitySelection parity = getRawContractParitySelection(argc, argv); + const char* isolated_user_dir = std::getenv("SECONDLIFE_USER_DIR"); + if ((parity.mTonemap || parity.mMaterial || parity.mTextureUpload) + && (!isolated_user_dir || isolated_user_dir[0] == '\0')) + { + std::fputs( + parity.mTonemap + ? "TONEMAP_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n" + : parity.mMaterial + ? "MATERIAL_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n" + : "TEXTURE_UPLOAD_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n", + stderr); + std::fflush(stderr); + return EXIT_FAILURE; + } + // Store off the command line args for use later. gArgC = argc; gArgV = argv; diff --git a/indra/newview/llappviewerwin32.cpp b/indra/newview/llappviewerwin32.cpp index 2080d0b7bfc..2ba4a12c72b 100644 --- a/indra/newview/llappviewerwin32.cpp +++ b/indra/newview/llappviewerwin32.cpp @@ -59,6 +59,9 @@ #include "llfindlocale.h" #include "llcommandlineparser.h" +#include "llcontractparityargs.h" +#include "fsyspath.h" +#include "llstring.h" #include "lltrans.h" #ifndef LL_RELEASE_FOR_DOWNLOAD @@ -71,6 +74,7 @@ #include #include +#include // Velopack installer and update framework #if LL_VELOPACK @@ -480,6 +484,58 @@ int APIENTRY WINMAIN(HINSTANCE hInstance, LL_PROFILER_FRAME_END; LL_PROFILER_SET_THREAD_NAME("App"); + const LLContractParitySelection parity = getRawContractParitySelection(__argc, __argv); + const bool parity_selected = parity.mTonemap || parity.mMaterial || parity.mTextureUpload; + const auto isolated_user_dir = LLStringUtil::getoptenv("SECONDLIFE_USER_DIR"); + if (parity_selected && (!isolated_user_dir || isolated_user_dir->empty())) + { + std::fputs( + parity.mTonemap + ? "TONEMAP_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n" + : parity.mMaterial + ? "MATERIAL_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n" + : "TEXTURE_UPLOAD_CONTRACT_PARITY result=fail reason=missing_SECONDLIFE_USER_DIR\n", + stderr); + std::fflush(stderr); + return -1; + } + if (parity_selected) + { + std::error_code error; + const fsyspath root(*isolated_user_dir); + std::filesystem::create_directories(root, error); + bool root_valid = !error && std::filesystem::is_directory(root, error) && !error; + if (root_valid) + { + const std::filesystem::path probe = + root / (".render-contract-isolation." + std::to_string(GetCurrentProcessId())); + HANDLE probe_handle = CreateFileW(probe.c_str(), GENERIC_WRITE | DELETE, FILE_SHARE_DELETE, nullptr, + CREATE_NEW, FILE_ATTRIBUTE_TEMPORARY | FILE_FLAG_DELETE_ON_CLOSE, + nullptr); + DWORD bytes_written = 0; + const char byte = 0; + const bool wrote = probe_handle != INVALID_HANDLE_VALUE && + WriteFile(probe_handle, &byte, sizeof(byte), &bytes_written, nullptr) && + bytes_written == sizeof(byte); + const bool closed = probe_handle != INVALID_HANDLE_VALUE && CloseHandle(probe_handle); + std::error_code exists_error; + const bool removed = !std::filesystem::exists(probe, exists_error) && !exists_error; + root_valid = wrote && closed && removed; + } + if (!root_valid) + { + std::fputs( + parity.mTonemap + ? "TONEMAP_CONTRACT_PARITY result=fail reason=invalid_SECONDLIFE_USER_DIR\n" + : parity.mMaterial + ? "MATERIAL_CONTRACT_PARITY result=fail reason=invalid_SECONDLIFE_USER_DIR\n" + : "TEXTURE_UPLOAD_CONTRACT_PARITY result=fail reason=invalid_SECONDLIFE_USER_DIR\n", + stderr); + std::fflush(stderr); + return -1; + } + } + const S32 MAX_HEAPS = 255; DWORD heap_enable_lfh_error[MAX_HEAPS]; S32 num_heaps = 0; diff --git a/indra/newview/llbenchmarkappearance.h b/indra/newview/llbenchmarkappearance.h new file mode 100644 index 00000000000..02cb9cbb359 --- /dev/null +++ b/indra/newview/llbenchmarkappearance.h @@ -0,0 +1,74 @@ +/** + * @file llbenchmarkappearance.h + * @brief Privacy-safe appearance classification for renderer benchmarks. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLBENCHMARKAPPEARANCE_H +#define LL_LLBENCHMARKAPPEARANCE_H + +#include + +namespace LLBenchmarkAppearance +{ +using RequiredParts = std::array; + +struct Facts +{ + bool avatar_valid = false; + bool cof_present = false; + bool cof_complete = false; + bool cof_change_in_progress = false; + RequiredParts required_links_resolved{}; + RequiredParts required_wearables_delivered{}; + bool avatar_loaded = false; +}; + +inline bool allRequiredParts(const RequiredParts& parts) +{ + for (bool present : parts) + { + if (!present) + { + return false; + } + } + return true; +} + +inline const char* classify(const Facts& facts) +{ + if (!facts.avatar_valid) + { + return "avatar-unavailable"; + } + if (!facts.cof_present || !facts.cof_complete) + { + return "cof-incomplete"; + } + if (!allRequiredParts(facts.required_links_resolved)) + { + return "required-link-missing-or-unresolved"; + } + if (!allRequiredParts(facts.required_wearables_delivered)) + { + return "wearable-delivery-pending-or-failed"; + } + if (!facts.avatar_loaded) + { + return "avatar-later-blocker"; + } + return "ready"; +} +} + +#endif diff --git a/indra/newview/llbenchmarkdisplay.h b/indra/newview/llbenchmarkdisplay.h new file mode 100644 index 00000000000..dbb593469c9 --- /dev/null +++ b/indra/newview/llbenchmarkdisplay.h @@ -0,0 +1,33 @@ +/** + * @file llbenchmarkdisplay.h + * @brief Display-scale helpers for renderer benchmarks. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLBENCHMARKDISPLAY_H +#define LL_LLBENCHMARKDISPLAY_H + +#include "stdtypes.h" + +namespace LLBenchmarkDisplay +{ +inline F32 configuredUIScale(F32 current_scale, F32 target_scale, F32 backing_scale) +{ + if (target_scale <= 0.f || backing_scale <= 0.f) + { + return current_scale; + } + return target_scale / backing_scale; +} +} + +#endif diff --git a/indra/newview/llcachemigration.h b/indra/newview/llcachemigration.h new file mode 100644 index 00000000000..79b463233a7 --- /dev/null +++ b/indra/newview/llcachemigration.h @@ -0,0 +1,30 @@ +/** + * @file llcachemigration.h + * @brief Cache-directory migration guards. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLCACHEMIGRATION_H +#define LL_LLCACHEMIGRATION_H + +#include "fsyspath.h" + +namespace LLCacheMigration +{ +inline bool required(const std::string& old_cache, + const std::string& new_cache) +{ + return fsyspath(old_cache).lexically_normal() != fsyspath(new_cache).lexically_normal(); +} +} + +#endif // LL_LLCACHEMIGRATION_H diff --git a/indra/newview/llcontractparityargs.cpp b/indra/newview/llcontractparityargs.cpp new file mode 100644 index 00000000000..575311a1ebe --- /dev/null +++ b/indra/newview/llcontractparityargs.cpp @@ -0,0 +1,147 @@ +/** + * @file llcontractparityargs.cpp + * @brief Early command-line selection for isolated renderer parity diagnostics. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "llcontractparityargs.h" + +#include "llstring.h" + +#include +#include + +namespace +{ +constexpr std::string_view MATERIAL_SETTING = "RenderMaterialContractParityTest"; +constexpr std::string_view TEXTURE_UPLOAD_SETTING = "RenderTextureUploadContractParityTest"; +constexpr std::string_view TONEMAP_SETTING = "RenderTonemapContractParityTest"; +constexpr std::string_view GLOBAL_PREFIX = "Global."; + +std::string_view argumentAt(int index, int argc, char* const* argv) +{ + if (!argv || index < 0 || index >= argc || !argv[index]) + { + return {}; + } + return argv[index]; +} + +bool isDirectFlag(std::string_view argument, std::string_view name) +{ + if (argument.starts_with("--")) + { + argument.remove_prefix(2); + } + else if (argument.starts_with("-")) + { + argument.remove_prefix(1); + } + else + { + return false; + } + return argument == name; +} + +std::string_view unqualifiedName(std::string_view name) +{ + if (name.starts_with(GLOBAL_PREFIX)) + { + name.remove_prefix(GLOBAL_PREFIX.size()); + } + return name; +} + +void applySetPair(std::string_view name, std::string_view value, LLContractParitySelection& selection) +{ + name = unqualifiedName(name); + if (name != MATERIAL_SETTING && name != TEXTURE_UPLOAD_SETTING && name != TONEMAP_SETTING) + { + return; + } + + bool enabled = false; + LLStringUtil::convertToBOOL(std::string(value), enabled); + if (name == MATERIAL_SETTING) + { + selection.mMaterial = enabled; + } + else if (name == TEXTURE_UPLOAD_SETTING) + { + selection.mTextureUpload = enabled; + } + else + { + selection.mTonemap = enabled; + } +} +} // namespace + +LLContractParitySelection getRawContractParitySelection(int argc, char* const* argv) +{ + LLContractParitySelection selection; + + for (int index = 1; index < argc; ++index) + { + const std::string_view argument = argumentAt(index, argc, argv); + if (argument == "--") + { + break; + } + if (isDirectFlag(argument, "tonemapparity")) + { + selection.mTonemap = true; + } + else if (isDirectFlag(argument, "materialparity")) + { + selection.mMaterial = true; + } + else if (isDirectFlag(argument, "textureuploadparity")) + { + selection.mTextureUpload = true; + } + } + + constexpr std::string_view LONG_SET_PREFIX = "--set="; + constexpr std::string_view SHORT_SET_PREFIX = "-set="; + for (int index = 1; index < argc; ++index) + { + const std::string_view argument = argumentAt(index, argc, argv); + if (argument == "--") + { + break; + } + if (argument == "--set" || argument == "-set") + { + if (index + 2 < argc) + { + applySetPair(argumentAt(index + 1, argc, argv), argumentAt(index + 2, argc, argv), selection); + index += 2; + } + } + else if (argument.starts_with(LONG_SET_PREFIX) && index + 1 < argc) + { + applySetPair(argument.substr(LONG_SET_PREFIX.size()), argumentAt(index + 1, argc, argv), selection); + ++index; + } + else if (argument.starts_with(SHORT_SET_PREFIX) && index + 1 < argc) + { + applySetPair(argument.substr(SHORT_SET_PREFIX.size()), argumentAt(index + 1, argc, argv), selection); + ++index; + } + } + + return selection; +} diff --git a/indra/newview/llcontractparityargs.h b/indra/newview/llcontractparityargs.h new file mode 100644 index 00000000000..11905598a0c --- /dev/null +++ b/indra/newview/llcontractparityargs.h @@ -0,0 +1,28 @@ +/** + * @file llcontractparityargs.h + * @brief Early command-line selection for isolated renderer parity diagnostics. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLCONTRACTPARITYARGS_H +#define LL_LLCONTRACTPARITYARGS_H + +struct LLContractParitySelection +{ + bool mTonemap = false; + bool mMaterial = false; + bool mTextureUpload = false; +}; + +LLContractParitySelection getRawContractParitySelection(int argc, char* const* argv); + +#endif diff --git a/indra/newview/lldrawinfo.cpp b/indra/newview/lldrawinfo.cpp new file mode 100644 index 00000000000..62104669f27 --- /dev/null +++ b/indra/newview/lldrawinfo.cpp @@ -0,0 +1,61 @@ +/** + * @file lldrawinfo.cpp + * @brief LLDrawInfo implementation. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "llspatialpartition.h" + +LLDrawInfo::LLDrawInfo(U16 start, U16 end, U32 count, U32 offset, LLViewerTexture* texture, LLVertexBuffer* buffer, bool fullbright, + U8 bump) : + mVertexBuffer(buffer), + mTexture(texture), + mStart(start), + mEnd(end), + mCount(count), + mOffset(offset), + mFullbright(fullbright), + mBump(bump), + mBlendFuncSrc(LLRender::BF_SOURCE_ALPHA), + mBlendFuncDst(LLRender::BF_ONE_MINUS_SOURCE_ALPHA), + mHasGlow(false), + mEnvIntensity(0.0f), + mAlphaMaskCutoff(0.5f) +{ + mVertexBuffer->validateRange(mStart, mEnd, mCount, mOffset); +} + +LLColor4U LLDrawInfo::getDebugColor() const +{ + LLColor4U color; + + LLCRC hash; + hash.update((U8*)this + sizeof(S32), sizeof(LLDrawInfo) - sizeof(S32)); + + *((U32*)color.mV) = hash.getCRC(); + + color.mV[3] = 200; + + return color; +} + +void LLDrawInfo::validate() +{ + mVertexBuffer->validateRange(mStart, mEnd, mCount, mOffset); +} + +U64 LLDrawInfo::getSkinHash() +{ + return mSkinInfo ? mSkinInfo->mHash : 0; +} diff --git a/indra/newview/lldrawinfotranslator.cpp b/indra/newview/lldrawinfotranslator.cpp new file mode 100644 index 00000000000..c2c0e7b6f65 --- /dev/null +++ b/indra/newview/lldrawinfotranslator.cpp @@ -0,0 +1,197 @@ +/** + * @file lldrawinfotranslator.cpp + * @brief Translation boundary from viewer draw state to an owned draw packet. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "lldrawinfotranslator.h" + +#include "lldrawpool.h" +#include "llspatialpartition.h" + +#include +#include + +namespace LLDrawInfoAdapter +{ +namespace +{ + + constexpr std::uint32_t REQUIRED_VERTEX_MASK = LLVertexBuffer::MAP_VERTEX | LLVertexBuffer::MAP_NORMAL | LLVertexBuffer::MAP_TEXCOORD0 | + LLVertexBuffer::MAP_TEXCOORD1 | LLVertexBuffer::MAP_TEXCOORD2 | + LLVertexBuffer::MAP_COLOR | LLVertexBuffer::MAP_TANGENT; + constexpr std::uint32_t KNOWN_VERTEX_MASK = (1u << LLVertexBuffer::TYPE_MAX) - 1u; + + bool productionPipelineKey(const LLRenderContract::LegacyNormSpecPipelineKey& key) + { + static const LLRenderContract::LegacyNormSpecPipelineKey modern = LLRenderContract::legacyNormSpecModernHDRPipelineKey(); + static const LLRenderContract::LegacyNormSpecPipelineKey compatibility = LLRenderContract::legacyNormSpecCompatibilityPipelineKey(); + return key == modern || key == compatibility; + } + + LLRenderContract::DrawMatrix4 copyMatrix(const LLMatrix4* matrix) + { + if (!matrix) + { + return LLRenderContract::DRAW_IDENTITY_MATRIX4; + } + + LLRenderContract::DrawMatrix4 result; + for (std::size_t row = 0; row < 4; ++row) + { + for (std::size_t column = 0; column < 4; ++column) + { + result[row * 4 + column] = matrix->mMatrix[row][column]; + } + } + return result; + } + + bool supportedSource(const LLDrawInfo& draw_info) + { + if (draw_info.mVertexBuffer.isNull() || draw_info.mTexture.isNull() || draw_info.mNormalMap.isNull() || + draw_info.mSpecularMap.isNull() || draw_info.mMaterial.isNull() || draw_info.mGLTFMaterial.notNull() || + draw_info.mAvatar.notNull() || draw_info.mSkinInfo || draw_info.mHasGlow || draw_info.mNormalMapMatrix || + draw_info.mSpecularMapMatrix) + { + return false; + } + + const std::uint32_t vertex_mask = draw_info.mVertexBuffer->getTypeMask(); + if ((vertex_mask & REQUIRED_VERTEX_MASK) != REQUIRED_VERTEX_MASK || (vertex_mask & ~KNOWN_VERTEX_MASK) != 0) + { + return false; + } + + for (const LLPointer& texture : draw_info.mTextureList) + { + if (texture.notNull() && texture.get() != draw_info.mTexture.get()) + { + return false; + } + } + return true; + } + + bool supportedGeometry(const LLDrawInfo& draw_info, const ResolvedGeometry& geometry) + { + if (!geometry.mVertexBuffer || !geometry.mIndexBuffer || geometry.mVertexBuffer == geometry.mIndexBuffer || + geometry.mVertexCount == 0 || geometry.mIndexCount == 0 || geometry.mVertexBufferSize == 0 || geometry.mIndexBufferSize == 0 || + draw_info.mCount == 0 || draw_info.mStart > draw_info.mEnd || draw_info.mEnd >= geometry.mVertexCount || + draw_info.mOffset > geometry.mIndexCount || draw_info.mCount > geometry.mIndexCount - draw_info.mOffset) + { + return false; + } + + std::uint64_t index_size = 0; + switch (geometry.mIndexType) + { + case LLRenderContract::IndexType::UInt16: + index_size = sizeof(std::uint16_t); + break; + case LLRenderContract::IndexType::UInt32: + index_size = sizeof(std::uint32_t); + break; + default: + return false; + } + + std::uint64_t required_vertex_bytes = 0; + for (std::uint32_t type = 0; type < LLVertexBuffer::TYPE_TEXTURE_INDEX; ++type) + { + if ((draw_info.mVertexBuffer->getTypeMask() & (1u << type)) != 0) + { + required_vertex_bytes += static_cast(LLVertexBuffer::sTypeSize[type]) * geometry.mVertexCount; + required_vertex_bytes = (required_vertex_bytes + 15u) & ~std::uint64_t{ 15u }; + } + } + const std::uint64_t required_index_bytes = index_size * geometry.mIndexCount; + const std::uint64_t draw_index_end = + index_size * (static_cast(draw_info.mOffset) + static_cast(draw_info.mCount)); + + return required_vertex_bytes <= geometry.mVertexBufferSize && required_index_bytes <= geometry.mIndexBufferSize && + draw_index_end <= geometry.mIndexBufferSize; + } + + bool consistentImageIdentity(const LLViewerTexture* left_source, + const LLRenderContract::DrawTextureInput& left, + const LLViewerTexture* right_source, + const LLRenderContract::DrawTextureInput& right) + { + return left_source != right_source || left.mImage == right.mImage; + } + +} // namespace + +std::optional translateNonRiggedNormSpecDraw(const LLDrawInfo& draw_info, + std::uint32_t render_type, const Context& context, + const Resolver& resolver) +{ + if (render_type != static_cast(LLRenderPass::PASS_NORMSPEC) || context.mSubmission != SubmissionKind::DeferredMaterial || + context.mRenderDomain != RenderDomain::World || context.mFrame == 0 || !context.mPass || + !productionPipelineKey(context.mPipelineKey) || !supportedSource(draw_info)) + { + return std::nullopt; + } + + const std::optional geometry = resolver.resolveGeometry(*draw_info.mVertexBuffer); + if (!geometry || !supportedGeometry(draw_info, *geometry)) + { + return std::nullopt; + } + + const std::optional diffuse = resolver.resolveImage(*draw_info.mTexture, TextureRole::Diffuse); + const std::optional normal = resolver.resolveImage(*draw_info.mNormalMap, TextureRole::Normal); + const std::optional specular = + resolver.resolveImage(*draw_info.mSpecularMap, TextureRole::Specular); + if (!diffuse || !normal || !specular || + !consistentImageIdentity(draw_info.mTexture.get(), *diffuse, draw_info.mNormalMap.get(), *normal) || + !consistentImageIdentity(draw_info.mTexture.get(), *diffuse, draw_info.mSpecularMap.get(), *specular) || + !consistentImageIdentity(draw_info.mNormalMap.get(), *normal, draw_info.mSpecularMap.get(), *specular)) + { + return std::nullopt; + } + + const std::optional pipeline = resolver.resolvePipeline(context.mPipelineKey); + if (!pipeline) + { + return std::nullopt; + } + + LLRenderContract::LegacyNormSpecDrawInputs inputs; + inputs.mFrame = context.mFrame; + inputs.mPass = context.mPass; + inputs.mHandles.mVertexBuffer = geometry->mVertexBuffer; + inputs.mHandles.mIndexBuffer = geometry->mIndexBuffer; + inputs.mHandles.mPipeline = *pipeline; + inputs.mDescriptors.mDiffuse = *diffuse; + inputs.mDescriptors.mNormal = *normal; + inputs.mDescriptors.mSpecular = *specular; + inputs.mPipelineKey = context.mPipelineKey; + inputs.mIndexType = geometry->mIndexType; + inputs.mFirstIndex = draw_info.mOffset; + inputs.mIndexCount = draw_info.mCount; + inputs.mMinVertex = draw_info.mStart; + inputs.mMaxVertex = draw_info.mEnd; + inputs.mModelMatrix = copyMatrix(draw_info.mModelMatrix); + inputs.mDiffuseTextureMatrix = copyMatrix(draw_info.mTextureMatrix); + std::copy_n(draw_info.mSpecColor.mV, inputs.mSpecularRGBA.size(), inputs.mSpecularRGBA.begin()); + inputs.mEnvironmentIntensity = draw_info.mEnvIntensity; + inputs.mAlphaCutoff = draw_info.mAlphaMaskCutoff; + inputs.mEmissiveBrightness = draw_info.mFullbright ? 1.f : 0.f; + + return LLRenderContract::buildLegacyNormSpecDrawPacket(inputs); +} + +} // namespace LLDrawInfoAdapter diff --git a/indra/newview/lldrawinfotranslator.h b/indra/newview/lldrawinfotranslator.h new file mode 100644 index 00000000000..36f75a5f582 --- /dev/null +++ b/indra/newview/lldrawinfotranslator.h @@ -0,0 +1,93 @@ +/** + * @file lldrawinfotranslator.h + * @brief Translation boundary from viewer draw state to an owned draw packet. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLDRAWINFOTRANSLATOR_H +#define LL_LLDRAWINFOTRANSLATOR_H + +#include "lldrawpacketcontract.h" + +#include +#include + +class LLDrawInfo; +class LLVertexBuffer; +class LLViewerTexture; + +namespace LLDrawInfoAdapter +{ + +enum class SubmissionKind : std::uint8_t +{ + Invalid, + DeferredMaterial +}; + +enum class RenderDomain : std::uint8_t +{ + Invalid, + World, + HUD, + Impostor, + Reflection, + Cube +}; + +enum class TextureRole : std::uint8_t +{ + Diffuse, + Normal, + Specular +}; + +struct Context +{ + std::uint64_t mFrame = 0; + LLRenderContract::PassId mPass; + LLRenderContract::LegacyNormSpecPipelineKey mPipelineKey; + RenderDomain mRenderDomain = RenderDomain::Invalid; + SubmissionKind mSubmission = SubmissionKind::Invalid; +}; + +struct ResolvedGeometry +{ + LLRenderContract::BufferHandle mVertexBuffer; + LLRenderContract::BufferHandle mIndexBuffer; + std::uint64_t mVertexBufferSize = 0; + std::uint64_t mIndexBufferSize = 0; + std::uint32_t mVertexCount = 0; + std::uint32_t mIndexCount = 0; + LLRenderContract::IndexType mIndexType = LLRenderContract::IndexType::UInt16; +}; + +class Resolver +{ +public: + virtual ~Resolver() = default; + + // Successful resolutions describe the source's current live generation and + // validated immutable metadata. Retired, stale, or policy-incompatible sources return nullopt. + virtual std::optional resolveGeometry(const LLVertexBuffer& buffer) const = 0; + virtual std::optional resolveImage(const LLViewerTexture& texture, TextureRole role) const = 0; + virtual std::optional resolvePipeline( + const LLRenderContract::LegacyNormSpecPipelineKey& key) const = 0; +}; + +std::optional translateNonRiggedNormSpecDraw(const LLDrawInfo& draw_info, + std::uint32_t render_type, const Context& context, + const Resolver& resolver); + +} // namespace LLDrawInfoAdapter + +#endif // LL_LLDRAWINFOTRANSLATOR_H diff --git a/indra/newview/llmaterialparity.cpp b/indra/newview/llmaterialparity.cpp new file mode 100644 index 00000000000..5858d4ad1fe --- /dev/null +++ b/indra/newview/llmaterialparity.cpp @@ -0,0 +1,1306 @@ +/** + * @file llmaterialparity.cpp + * @brief Account-free parity harness for the indexed deferred material draw. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "llmaterialparity.h" + +#include "llgl.h" +#include "llglslshader.h" +#include "llglstates.h" +#include "llimagegl.h" +#include "llmaterialdiagnostic.h" +#include "llrenderglmaterial.h" +#include "llrendertarget.h" +#include "llshadermgr.h" +#include "llvertexbuffer.h" +#include "llviewercontrol.h" +#include "llviewershadermgr.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace LLMaterialParity +{ +namespace +{ + + using namespace LLRenderContract; + + constexpr U32 MATERIAL_VERTEX_MASK = LLVertexBuffer::MAP_VERTEX | LLVertexBuffer::MAP_NORMAL | LLVertexBuffer::MAP_TEXCOORD0 | + LLVertexBuffer::MAP_TEXCOORD1 | LLVertexBuffer::MAP_TEXCOORD2 | LLVertexBuffer::MAP_COLOR | + LLVertexBuffer::MAP_TANGENT; + constexpr std::uint32_t DEPTH24_MAX = 0xffffffU; + constexpr const char* MATERIAL_PROGRAM = "deferred.material.normspec"; + + struct MaterialResources + { + LLPointer mGeometry; + LLPointer mIncompatibleGeometry; + LLPointer mIncompatibleIndexGeometry; + std::array mTextures{}; + std::array mIncompatibleTextures{}; + GLuint mPoisonSampler = 0; + LLRenderTarget mTarget; + LLRenderTarget mIncompatibleColorTarget; + LLRenderTarget mIncompatibleDepthTarget; + + MaterialResources() = default; + MaterialResources(const MaterialResources&) = delete; + MaterialResources& operator=(const MaterialResources&) = delete; + + ~MaterialResources() + { + if (mPoisonSampler != 0) + { + glDeleteSamplers(1, &mPoisonSampler); + } + for (const auto* textures : { &mTextures, &mIncompatibleTextures }) + { + for (GLuint texture : *textures) + { + if (texture != 0) + { + LLImageGL::deleteTextures(1, &texture); + } + } + } + } + }; + + struct MaterialReadback + { + std::array mGBuffer0{}; + std::array mGBuffer1{}; + std::array mGBuffer2{}; + std::array mDepth{}; + + friend bool operator==(const MaterialReadback&, const MaterialReadback&) = default; + }; + + struct SubmissionResult + { + bool mPoisoned = false; + bool mAccepted = false; + GLenum mError = GL_NO_ERROR; + }; + + bool emitFailure(const std::string& reason) + { + const std::string result = "MATERIAL_CONTRACT_PARITY result=fail reason=" + reason; + std::cout << result << std::endl; + LL_INFOS("RenderContractParity") << result << LL_ENDL; + return false; + } + + bool initializeGeometry(MaterialResources& resources, const MaterialFixture& fixture) + { + resources.mGeometry = new LLVertexBuffer(MATERIAL_VERTEX_MASK); + if (!resources.mGeometry->allocateBuffer(4, 6) || resources.mGeometry->getSize() != MATERIAL_VERTEX_BUFFER_SIZE || + resources.mGeometry->getIndicesSize() != MATERIAL_INDEX_BUFFER_SIZE || + resources.mGeometry->getOffset(LLVertexBuffer::TYPE_VERTEX) != MATERIAL_POSITION_OFFSET || + resources.mGeometry->getOffset(LLVertexBuffer::TYPE_NORMAL) != MATERIAL_NORMAL_OFFSET || + resources.mGeometry->getOffset(LLVertexBuffer::TYPE_TEXCOORD0) != MATERIAL_TEXCOORD0_OFFSET || + resources.mGeometry->getOffset(LLVertexBuffer::TYPE_TEXCOORD1) != MATERIAL_TEXCOORD1_OFFSET || + resources.mGeometry->getOffset(LLVertexBuffer::TYPE_TEXCOORD2) != MATERIAL_TEXCOORD2_OFFSET || + resources.mGeometry->getOffset(LLVertexBuffer::TYPE_COLOR) != MATERIAL_COLOR_OFFSET || + resources.mGeometry->getOffset(LLVertexBuffer::TYPE_TANGENT) != MATERIAL_TANGENT_OFFSET) + { + return false; + } + + auto copy_attribute = [&](LLVertexBuffer::AttributeType type, std::size_t offset, std::size_t size) + { + U8* destination = resources.mGeometry->mapVertexBuffer(type, 0, 4); + if (!destination) + { + return false; + } + std::memcpy(destination, fixture.mVertexBytes.data() + offset, size); + return true; + }; + + const bool copied = copy_attribute(LLVertexBuffer::TYPE_VERTEX, MATERIAL_POSITION_OFFSET, 4 * sizeof(LLVector4)) && + copy_attribute(LLVertexBuffer::TYPE_NORMAL, MATERIAL_NORMAL_OFFSET, 4 * sizeof(LLVector4)) && + copy_attribute(LLVertexBuffer::TYPE_TEXCOORD0, MATERIAL_TEXCOORD0_OFFSET, 4 * sizeof(LLVector2)) && + copy_attribute(LLVertexBuffer::TYPE_TEXCOORD1, MATERIAL_TEXCOORD1_OFFSET, 4 * sizeof(LLVector2)) && + copy_attribute(LLVertexBuffer::TYPE_TEXCOORD2, MATERIAL_TEXCOORD2_OFFSET, 4 * sizeof(LLVector2)) && + copy_attribute(LLVertexBuffer::TYPE_COLOR, MATERIAL_COLOR_OFFSET, 4 * sizeof(LLColor4U)) && + copy_attribute(LLVertexBuffer::TYPE_TANGENT, MATERIAL_TANGENT_OFFSET, 4 * sizeof(LLVector4)); + U8* indices = resources.mGeometry->mapIndexBuffer(0, 6); + if (indices) + { + std::memcpy(indices, fixture.mIndices.data(), sizeof(fixture.mIndices)); + } + resources.mGeometry->unmapBuffer(); + return copied && indices != nullptr; + } + + bool initializeIncompatibleGeometry(MaterialResources& resources) + { + resources.mIncompatibleGeometry = new LLVertexBuffer(MATERIAL_VERTEX_MASK & ~LLVertexBuffer::MAP_TANGENT); + if (!resources.mIncompatibleGeometry->allocateBuffer(4, 6)) + { + return false; + } + + resources.mIncompatibleIndexGeometry = new LLVertexBuffer(MATERIAL_VERTEX_MASK); + if (!resources.mIncompatibleIndexGeometry->allocateBuffer(4, 6)) + { + return false; + } + constexpr std::array incompatible_indices{ 0, 2, 1, 0, 3, 2 }; + U8* indices = resources.mIncompatibleIndexGeometry->mapIndexBuffer(0, 6); + if (!indices) + { + return false; + } + std::memcpy(indices, incompatible_indices.data(), sizeof(incompatible_indices)); + resources.mIncompatibleIndexGeometry->unmapBuffer(); + return true; + } + + bool initializeTextures(MaterialResources& resources, const MaterialFixture& fixture) + { + if (!gGLManager.mHasAnisotropic || gGLManager.mMaxAnisotropy < 8.f) + { + return false; + } + + clear_glerror(); + glGenSamplers(1, &resources.mPoisonSampler); + if (resources.mPoisonSampler == 0) + { + return false; + } + glSamplerParameteri(resources.mPoisonSampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST); + glSamplerParameteri(resources.mPoisonSampler, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glSamplerParameteri(resources.mPoisonSampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glSamplerParameteri(resources.mPoisonSampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glSamplerParameteri(resources.mPoisonSampler, GL_TEXTURE_COMPARE_MODE, GL_NONE); + glSamplerParameterf(resources.mPoisonSampler, GL_TEXTURE_MIN_LOD, 1.f); + glSamplerParameterf(resources.mPoisonSampler, GL_TEXTURE_MAX_LOD, 1.f); + glSamplerParameterf(resources.mPoisonSampler, GL_TEXTURE_LOD_BIAS, 2.f); + glSamplerParameterf(resources.mPoisonSampler, GL_TEXTURE_MAX_ANISOTROPY, 1.f); + + for (std::size_t texture_index = 0; texture_index < resources.mTextures.size(); ++texture_index) + { + GLuint& texture = resources.mTextures[texture_index]; + LLImageGL::generateTextures(1, &texture); + if (texture == 0 || !gGL.getTexUnit(0)->bindManual(LLTexUnit::TT_TEXTURE, texture, true)) + { + return false; + } + + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, MATERIAL_TEXTURE_MIP_LEVELS - 1); + for (std::size_t mip = 0; mip < MATERIAL_TEXTURE_MIP_LEVELS; ++mip) + { + const GLsizei extent = static_cast(MATERIAL_TEXTURE_WIDTH >> mip); + LLImageGL::setManualImage(GL_TEXTURE_2D, static_cast(mip), GL_RGBA8, extent, extent, GL_RGBA, GL_UNSIGNED_BYTE, + fixture.mTextureRGBA8[texture_index].data() + MATERIAL_TEXTURE_MIP_BYTE_OFFSETS[mip], false); + } + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_NONE); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_LOD, -1000.f); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_LOD, 1000.f); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_LOD_BIAS, 0.f); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, GL_RED); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_G, GL_GREEN); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, GL_BLUE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, GL_ALPHA); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, 8.f); + } + return glGetError() == GL_NO_ERROR; + } + + bool initializeIncompatibleTextures(MaterialResources& resources) + { + clear_glerror(); + LLImageGL::generateTextures(static_cast(resources.mIncompatibleTextures.size()), resources.mIncompatibleTextures.data()); + for (std::size_t texture_index = 0; texture_index < resources.mIncompatibleTextures.size(); ++texture_index) + { + const GLuint texture = resources.mIncompatibleTextures[texture_index]; + if (texture == 0 || !gGL.getTexUnit(0)->bindManual(LLTexUnit::TT_TEXTURE, texture, true)) + { + return false; + } + + const GLint internal_format = texture_index == 0 ? GL_RGBA16 : GL_RGBA8; + const GLsizei base_extent = texture_index == 1 ? 8 : MATERIAL_TEXTURE_WIDTH; + const GLsizei mip_levels = texture_index == 2 ? MATERIAL_TEXTURE_MIP_LEVELS - 1 : MATERIAL_TEXTURE_MIP_LEVELS; + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, mip_levels - 1); + for (GLint mip = 0; mip < mip_levels; ++mip) + { + const GLsizei extent = std::max(1, base_extent >> mip); + LLImageGL::setManualImage(GL_TEXTURE_2D, mip, internal_format, extent, extent, GL_RGBA, GL_UNSIGNED_BYTE, nullptr, false); + } + } + return glGetError() == GL_NO_ERROR; + } + + bool initializeTarget(MaterialResources& resources) + { + return resources.mTarget.allocate(MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_RGBA, true) && + resources.mTarget.addColorAttachment(GL_RGBA) && resources.mTarget.addColorAttachment(GL_RGBA16) && + resources.mTarget.isComplete() && resources.mTarget.getNumTextures() == 3 && + resources.mTarget.getColorFormat(0) == GL_RGBA && resources.mTarget.getColorFormat(1) == GL_RGBA && + resources.mTarget.getColorFormat(2) == GL_RGBA16 && resources.mTarget.getDepth() != 0; + } + + bool initializeIncompatibleTargets(MaterialResources& resources) + { + const bool color_target = + resources.mIncompatibleColorTarget.allocate(MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_RGBA16, true) && + resources.mIncompatibleColorTarget.addColorAttachment(GL_RGBA) && + resources.mIncompatibleColorTarget.addColorAttachment(GL_RGBA16); + const bool depth_target = resources.mIncompatibleDepthTarget.allocate(MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_RGBA, true) && + resources.mIncompatibleDepthTarget.addColorAttachment(GL_RGBA) && + resources.mIncompatibleDepthTarget.addColorAttachment(GL_RGBA16); + if (!color_target || !depth_target || + !gGL.getTexUnit(0)->bindManual(resources.mIncompatibleDepthTarget.getUsage(), resources.mIncompatibleDepthTarget.getDepth())) + { + return false; + } + LLImageGL::setManualImage(GL_TEXTURE_2D, 0, GL_DEPTH_COMPONENT, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_DEPTH_COMPONENT, + GL_UNSIGNED_SHORT, nullptr, false); + return glGetError() == GL_NO_ERROR; + } + + bool initializeResources(MaterialResources& resources, const MaterialFixture& fixture) + { + return initializeGeometry(resources, fixture) && initializeIncompatibleGeometry(resources) && + initializeTextures(resources, fixture) && initializeIncompatibleTextures(resources) && initializeTarget(resources) && + initializeIncompatibleTargets(resources); + } + + bool seedTarget(LLRenderTarget& target, const MaterialFixture& fixture) + { + std::array depth{}; + std::transform(fixture.mDepth24.begin(), fixture.mDepth24.end(), depth.begin(), materialDepth24); + + GLint unpack_alignment = 4; + glGetIntegerv(GL_UNPACK_ALIGNMENT, &unpack_alignment); + clear_glerror(); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + + gGL.getTexUnit(0)->bindManual(target.getUsage(), target.getTexture(0)); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE, + fixture.mGBuffer0SentinelRGBA8.data()); + gGL.getTexUnit(0)->bindManual(target.getUsage(), target.getTexture(1)); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE, + fixture.mGBuffer1SentinelRGBA8.data()); + gGL.getTexUnit(0)->bindManual(target.getUsage(), target.getTexture(2)); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_RGBA, GL_UNSIGNED_SHORT, + fixture.mGBuffer2SentinelRGBA16.data()); + gGL.getTexUnit(0)->bindManual(target.getUsage(), target.getDepth()); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_DEPTH_COMPONENT, GL_FLOAT, depth.data()); + + const bool success = glGetError() == GL_NO_ERROR; + glPixelStorei(GL_UNPACK_ALIGNMENT, unpack_alignment); + return success; + } + + bool readTarget(LLRenderTarget& target, MaterialReadback& readback) + { + std::array depth{}; + GLint pack_alignment = 4; + glGetIntegerv(GL_PACK_ALIGNMENT, &pack_alignment); + clear_glerror(); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + + target.bindTarget(); + glReadBuffer(GL_COLOR_ATTACHMENT0); + glReadPixels(0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE, readback.mGBuffer0.data()); + glReadBuffer(GL_COLOR_ATTACHMENT1); + glReadPixels(0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_RGBA, GL_UNSIGNED_BYTE, readback.mGBuffer1.data()); + glReadBuffer(GL_COLOR_ATTACHMENT2); + glReadPixels(0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_RGBA, GL_UNSIGNED_SHORT, readback.mGBuffer2.data()); + glReadPixels(0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT, GL_DEPTH_COMPONENT, GL_FLOAT, depth.data()); + const bool gl_success = glGetError() == GL_NO_ERROR; + target.flush(); + glPixelStorei(GL_PACK_ALIGNMENT, pack_alignment); + if (!gl_success) + { + return false; + } + + for (std::size_t pixel = 0; pixel < depth.size(); ++pixel) + { + if (!std::isfinite(depth[pixel]) || depth[pixel] < 0.f || depth[pixel] > 1.f) + { + return false; + } + readback.mDepth[pixel] = static_cast(std::llround(static_cast(depth[pixel]) * DEPTH24_MAX)); + } + return true; + } + + MaterialArtifact artifactFrom(const MaterialReadback& readback) + { + MaterialArtifact artifact = makeMaterialArtifact(); + artifact.mGBuffer0RGBA8.reserve(readback.mGBuffer0.size()); + artifact.mGBuffer1RGBA8.reserve(readback.mGBuffer1.size()); + artifact.mGBuffer2RGBA16.reserve(readback.mGBuffer2.size()); + artifact.mDepth24.reserve(readback.mDepth.size()); + std::transform(readback.mGBuffer0.begin(), readback.mGBuffer0.end(), std::back_inserter(artifact.mGBuffer0RGBA8), materialUnorm8); + std::transform(readback.mGBuffer1.begin(), readback.mGBuffer1.end(), std::back_inserter(artifact.mGBuffer1RGBA8), materialUnorm8); + std::transform(readback.mGBuffer2.begin(), readback.mGBuffer2.end(), std::back_inserter(artifact.mGBuffer2RGBA16), materialUnorm16); + std::transform(readback.mDepth.begin(), readback.mDepth.end(), std::back_inserter(artifact.mDepth24), materialDepth24); + return artifact; + } + + void eraseCachedUniform(LLGLSLShader& shader, U32 uniform) + { + const GLint location = shader.getUniformLocation(uniform); + if (location >= 0) + { + shader.mValue.erase(location); + } + } + + void applyParameters(LLGLSLShader& shader, const MaterialParameters& parameters) + { + // drawRange() synchronizes viewer matrices. Synchronize first, then upload + // the canonical matrices so that synchronization cannot overwrite them. + gGL.syncMatrices(); + shader.uniformMatrix4fv(LLShaderMgr::MODELVIEW_MATRIX, 1, GL_FALSE, parameters.mModelviewMatrix.data()); + shader.uniformMatrix4fv(LLShaderMgr::MODELVIEW_PROJECTION_MATRIX, 1, GL_FALSE, parameters.mModelviewProjectionMatrix.data()); + shader.uniformMatrix3fv(LLShaderMgr::NORMAL_MATRIX, 1, GL_FALSE, parameters.mNormalMatrix.data()); + shader.uniformMatrix4fv(LLShaderMgr::TEXTURE_MATRIX0, 1, GL_FALSE, parameters.mTextureMatrix0.data()); + + constexpr std::array cached_uniforms{ LLShaderMgr::DIFFUSE_MAP, LLShaderMgr::BUMP_MAP, + LLShaderMgr::SPECULAR_MAP, LLShaderMgr::EMISSIVE_BRIGHTNESS, + LLShaderMgr::ENVIRONMENT_INTENSITY, LLShaderMgr::SPECULAR_COLOR, + LLShaderMgr::MIRROR_FLAG, LLShaderMgr::CLIP_PLANE }; + for (U32 uniform : cached_uniforms) + { + eraseCachedUniform(shader, uniform); + } + + shader.uniform1i(LLShaderMgr::DIFFUSE_MAP, LLRender::DIFFUSE_MAP); + shader.uniform1i(LLShaderMgr::BUMP_MAP, LLRender::NORMAL_MAP); + shader.uniform1i(LLShaderMgr::SPECULAR_MAP, LLRender::SPECULAR_MAP); + shader.uniform1f(LLShaderMgr::EMISSIVE_BRIGHTNESS, parameters.mEmissiveBrightness); + shader.uniform1f(LLShaderMgr::ENVIRONMENT_INTENSITY, parameters.mEnvironmentIntensity); + shader.uniform4fv(LLShaderMgr::SPECULAR_COLOR, 1, parameters.mSpecularColor.data()); + shader.uniform1f(LLShaderMgr::MIRROR_FLAG, parameters.mMirror); + shader.uniform4fv(LLShaderMgr::CLIP_PLANE, 1, parameters.mClipPlane.data()); + } + + bool bindMaterialTexture(U32 unit_index, GLuint texture) + { + glBindSampler(unit_index, 0); + LLTexUnit* unit = gGL.getTexUnit(unit_index); + if (!unit->bindManual(LLTexUnit::TT_TEXTURE, texture, true)) + { + return false; + } + unit->activate(); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, MATERIAL_TEXTURE_MIP_LEVELS - 1); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_NONE); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_LOD, -1000.f); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_LOD, 1000.f); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_LOD_BIAS, 0.f); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, GL_RED); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_G, GL_GREEN); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, GL_BLUE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, GL_ALPHA); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, 8.f); + return true; + } + + class DrawStateRestore + { + public: + DrawStateRestore() + { + for (std::size_t i = 0; i < mCapabilities.size(); ++i) + { + mEnabled[i] = glIsEnabled(mCapabilities[i]); + } + glGetIntegerv(GL_VIEWPORT, mViewport.data()); + glGetIntegerv(GL_SCISSOR_BOX, mScissor.data()); + glGetBooleanv(GL_COLOR_WRITEMASK, mColorMask.data()); + glGetBooleanv(GL_DEPTH_WRITEMASK, &mDepthMask); + glGetIntegerv(GL_DEPTH_FUNC, &mDepthFunction); + glGetDoublev(GL_DEPTH_RANGE, mDepthRange.data()); + glGetIntegerv(GL_CULL_FACE_MODE, &mCullFace); + glGetIntegerv(GL_FRONT_FACE, &mFrontFace); + glGetIntegerv(GL_POLYGON_MODE, mPolygonMode.data()); + glGetIntegerv(GL_PROVOKING_VERTEX, &mProvokingVertex); + glGetIntegerv(GL_BLEND_SRC_RGB, &mBlendSourceRgb); + glGetIntegerv(GL_BLEND_DST_RGB, &mBlendDestinationRgb); + glGetIntegerv(GL_BLEND_SRC_ALPHA, &mBlendSourceAlpha); + glGetIntegerv(GL_BLEND_DST_ALPHA, &mBlendDestinationAlpha); + glGetIntegerv(GL_BLEND_EQUATION_RGB, &mBlendEquationRgb); + glGetIntegerv(GL_BLEND_EQUATION_ALPHA, &mBlendEquationAlpha); + } + + ~DrawStateRestore() + { + for (std::size_t i = 0; i < mCapabilities.size(); ++i) + { + mEnabled[i] ? glEnable(mCapabilities[i]) : glDisable(mCapabilities[i]); + } + glViewport(mViewport[0], mViewport[1], mViewport[2], mViewport[3]); + glScissor(mScissor[0], mScissor[1], mScissor[2], mScissor[3]); + glColorMask(mColorMask[0], mColorMask[1], mColorMask[2], mColorMask[3]); + glDepthMask(mDepthMask); + glDepthFunc(static_cast(mDepthFunction)); + glDepthRange(mDepthRange[0], mDepthRange[1]); + glCullFace(static_cast(mCullFace)); + glFrontFace(static_cast(mFrontFace)); + glPolygonMode(GL_FRONT_AND_BACK, static_cast(mPolygonMode[0])); + glProvokingVertex(static_cast(mProvokingVertex)); + glBlendFuncSeparate(static_cast(mBlendSourceRgb), static_cast(mBlendDestinationRgb), + static_cast(mBlendSourceAlpha), static_cast(mBlendDestinationAlpha)); + glBlendEquationSeparate(static_cast(mBlendEquationRgb), static_cast(mBlendEquationAlpha)); + } + + void applyMaterialState() + { + constexpr std::array enabled{ GL_FALSE, GL_TRUE, GL_TRUE, GL_TRUE, GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE, + GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE }; + applyCapabilities(enabled); + glViewport(0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT); + glScissor(0, 0, MATERIAL_FRAME_WIDTH, MATERIAL_FRAME_HEIGHT); + glColorMask(GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE); + glDepthMask(GL_TRUE); + glDepthFunc(GL_LEQUAL); + glDepthRange(0.0, 1.0); + glCullFace(GL_BACK); + glFrontFace(GL_CCW); + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); + glProvokingVertex(GL_LAST_VERTEX_CONVENTION); + glBlendFuncSeparate(GL_ONE, GL_ZERO, GL_ONE, GL_ZERO); + glBlendEquationSeparate(GL_FUNC_ADD, GL_FUNC_ADD); + } + + void applyPoisonState() + { + constexpr std::array enabled{ GL_TRUE, GL_FALSE, GL_FALSE, GL_FALSE, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE, + GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE, GL_TRUE }; + applyCapabilities(enabled); + glViewport(1, 2, 3, 4); + glScissor(2, 1, 1, 2); + glColorMask(GL_FALSE, GL_FALSE, GL_FALSE, GL_FALSE); + glDepthMask(GL_FALSE); + glDepthFunc(GL_ALWAYS); + glDepthRange(0.75, 0.25); + glCullFace(GL_FRONT); + glFrontFace(GL_CW); + glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); + glProvokingVertex(GL_FIRST_VERTEX_CONVENTION); + glBlendFuncSeparate(GL_SRC_ALPHA, GL_ONE_MINUS_SRC_ALPHA, GL_ZERO, GL_ONE); + glBlendEquationSeparate(GL_FUNC_REVERSE_SUBTRACT, GL_FUNC_SUBTRACT); + } + + private: + void applyCapabilities(const std::array& enabled) + { + for (std::size_t i = 0; i < mCapabilities.size(); ++i) + { + enabled[i] ? glEnable(mCapabilities[i]) : glDisable(mCapabilities[i]); + } + } + + const std::array mCapabilities{ GL_BLEND, + GL_CULL_FACE, + GL_DEPTH_TEST, + GL_SCISSOR_TEST, + GL_STENCIL_TEST, + GL_POLYGON_OFFSET_FILL, + GL_SAMPLE_ALPHA_TO_COVERAGE, + GL_SAMPLE_COVERAGE, + GL_FRAMEBUFFER_SRGB, + GL_COLOR_LOGIC_OP, + GL_RASTERIZER_DISCARD, + GL_PRIMITIVE_RESTART, + GL_DEPTH_CLAMP, + GL_DITHER, + GL_MULTISAMPLE }; + std::array mEnabled{}; + std::array mViewport{}; + std::array mScissor{}; + std::array mColorMask{}; + GLboolean mDepthMask = GL_TRUE; + GLint mDepthFunction = GL_LESS; + std::array mDepthRange{}; + GLint mCullFace = GL_BACK; + GLint mFrontFace = GL_CCW; + std::array mPolygonMode{}; + GLint mProvokingVertex = GL_LAST_VERTEX_CONVENTION; + GLint mBlendSourceRgb = GL_ONE; + GLint mBlendDestinationRgb = GL_ZERO; + GLint mBlendSourceAlpha = GL_ONE; + GLint mBlendDestinationAlpha = GL_ZERO; + GLint mBlendEquationRgb = GL_FUNC_ADD; + GLint mBlendEquationAlpha = GL_FUNC_ADD; + }; + + bool submitLegacy(MaterialResources& resources, LLGLSLShader& shader, const FrameSnapshot& frame) + { + const std::optional inputs = decodeMaterialFrame(frame); + if (!inputs) + { + return false; + } + + shader.bind(); + resources.mTarget.bindTarget(); + bool bound = false; + { + DrawStateRestore state; + state.applyMaterialState(); + + constexpr GLfloat clear[4] = { 0.f, 0.f, 0.f, 0.f }; + glClearBufferfv(GL_COLOR, 0, clear); + glClearBufferfv(GL_COLOR, 1, clear); + glClearBufferfv(GL_COLOR, 2, clear); + + applyParameters(shader, inputs->mParameters); + bound = bindMaterialTexture(LLRender::DIFFUSE_MAP, resources.mTextures[0]) && + bindMaterialTexture(LLRender::NORMAL_MAP, resources.mTextures[1]) && + bindMaterialTexture(LLRender::SPECULAR_MAP, resources.mTextures[2]); + if (bound) + { + resources.mGeometry->setBuffer(); + for (GLuint location : { GLuint{ LLVertexBuffer::TYPE_VERTEX }, GLuint{ LLVertexBuffer::TYPE_NORMAL }, + GLuint{ LLVertexBuffer::TYPE_TEXCOORD0 }, GLuint{ LLVertexBuffer::TYPE_TEXCOORD1 }, + GLuint{ LLVertexBuffer::TYPE_TEXCOORD2 }, GLuint{ LLVertexBuffer::TYPE_COLOR }, + GLuint{ LLVertexBuffer::TYPE_TANGENT } }) + { + glVertexAttribDivisor(location, 0); + } + resources.mGeometry->drawRange(LLRender::TRIANGLES, 0, 3, 6, 0); + } + + for (U32 unit : { U32{ LLRender::DIFFUSE_MAP }, U32{ LLRender::NORMAL_MAP }, U32{ LLRender::SPECULAR_MAP } }) + { + glBindSampler(unit, 0); + gGL.getTexUnit(unit)->unbind(LLTexUnit::TT_TEXTURE); + } + LLGLSLShader::unbind(); + } + resources.mTarget.flush(); + return bound; + } + + class TextureBindingRestore + { + public: + TextureBindingRestore() : mActiveUnit(gGL.getCurrentTexUnitIndex()) + { + for (U32 unit = 0; unit < mTextureBindings.size(); ++unit) + { + gGL.getTexUnit(unit)->activate(); + glGetIntegerv(GL_TEXTURE_BINDING_2D, &mTextureBindings[unit]); + glGetIntegeri_v(GL_SAMPLER_BINDING, unit, &mSamplerBindings[unit]); + } + gGL.getTexUnit(mActiveUnit)->activate(); + } + + ~TextureBindingRestore() + { + for (U32 unit = 0; unit < mTextureBindings.size(); ++unit) + { + LLTexUnit* texture_unit = gGL.getTexUnit(unit); + texture_unit->bindManual(LLTexUnit::TT_TEXTURE, static_cast(mTextureBindings[unit]), true); + texture_unit->activate(); + // unbind(TT_TEXTURE) substitutes the viewer white texture. Restore + // the exact raw binding too, including an original zero binding. + glBindTexture(GL_TEXTURE_2D, static_cast(mTextureBindings[unit])); + glBindSampler(unit, static_cast(mSamplerBindings[unit])); + } + gGL.getTexUnit(mActiveUnit)->activate(); + } + + private: + U32 mActiveUnit = 0; + std::array mTextureBindings{}; + std::array mSamplerBindings{}; + }; + + bool poisonTexture(U32 unit_index, GLuint texture, GLuint sampler) + { + LLTexUnit* unit = gGL.getTexUnit(unit_index); + if (!unit->bindManual(LLTexUnit::TT_TEXTURE, texture, true)) + { + return false; + } + unit->activate(); + glBindSampler(unit_index, sampler); + glBindTexture(GL_TEXTURE_2D, texture); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 1); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, 1); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_NEAREST_MIPMAP_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_NEAREST); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_MIRRORED_REPEAT); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_NONE); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_LOD, 1.f); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_LOD, 1.f); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_LOD_BIAS, 2.f); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, GL_BLUE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_G, GL_ALPHA); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, GL_RED); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, GL_GREEN); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, 1.f); + return true; + } + + bool poisonBindings(MaterialResources& resources, LLGLSLShader& shader) + { + MaterialParameters poison; + poison.mModelviewMatrix.fill(0.25f); + poison.mModelviewProjectionMatrix.fill(-0.5f); + poison.mNormalMatrix.fill(0.75f); + poison.mTextureMatrix0.fill(0.125f); + poison.mSpecularColor = { 0.91f, 0.07f, 0.43f, 0.19f }; + poison.mClipPlane = { -0.4f, 0.3f, -0.2f, -8.f }; + poison.mEnvironmentIntensity = 0.03f; + poison.mEmissiveBrightness = 0.97f; + poison.mMirror = 1.f; + + clear_glerror(); + shader.bind(); + applyParameters(shader, poison); + const bool bound = poisonTexture(LLRender::DIFFUSE_MAP, resources.mTextures[2], resources.mPoisonSampler) && + poisonTexture(LLRender::NORMAL_MAP, resources.mTextures[0], resources.mPoisonSampler) && + poisonTexture(LLRender::SPECULAR_MAP, resources.mTextures[1], resources.mPoisonSampler); + resources.mGeometry->setBuffer(); + glVertexAttribDivisor(LLVertexBuffer::TYPE_VERTEX, 2); + glVertexAttribDivisor(LLVertexBuffer::TYPE_TANGENT, 1); + LLGLSLShader::unbind(); + return bound && glGetError() == GL_NO_ERROR; + } + + template + SubmissionResult submitPoisoned(MaterialResources& poison_resources, LLGLSLShader& shader, Submit&& submit) + { + SubmissionResult result; + TextureBindingRestore bindings; + result.mPoisoned = poisonBindings(poison_resources, shader); + if (!result.mPoisoned) + { + return result; + } + + { + DrawStateRestore poison; + clear_glerror(); + poison_resources.mTarget.bindTarget(); + if (glGetError() == GL_NO_ERROR) + { + poison.applyPoisonState(); + result.mAccepted = submit(); + result.mError = glGetError(); + } + else + { + result.mError = GL_INVALID_FRAMEBUFFER_OPERATION; + } + poison_resources.mTarget.flush(); + if (const GLenum restore_error = glGetError(); result.mError == GL_NO_ERROR) + { + result.mError = restore_error; + } + } + return result; + } + + template + Handle stale(Handle handle) + { + ++handle.mGeneration; + return handle; + } + + enum class RegistryMutation + { + Unchanged, + StaleVertex, + StaleIndex, + StaleDiffuse, + StaleNormal, + StaleSpecular, + StaleSampler, + StalePipeline, + StaleGBuffer0, + StaleGBuffer1, + StaleGBuffer2, + StaleDepth, + WrongProgram, + WrongVariant, + WrongFormat, + WrongExtent, + WrongMips, + WrongSampler, + LiveWrongLayout, + LiveWrongIndices, + LiveWrongTextureFormat, + LiveWrongTextureExtent, + LiveWrongTextureMips, + LiveWrongColorTarget, + LiveWrongDepthTarget + }; + + bool registerResources(LLRenderGLMaterial::Registry& registry, + const MaterialInputs& inputs, + MaterialResources& resources, + LLGLSLShader& shader, + RegistryMutation mutation) + { + MaterialHandles handles = inputs.mHandles; + if (mutation == RegistryMutation::StaleVertex) + handles.mVertexBuffer = stale(handles.mVertexBuffer); + if (mutation == RegistryMutation::StaleIndex) + handles.mIndexBuffer = stale(handles.mIndexBuffer); + if (mutation == RegistryMutation::StaleDiffuse) + handles.mDiffuse = stale(handles.mDiffuse); + if (mutation == RegistryMutation::StaleNormal) + handles.mNormal = stale(handles.mNormal); + if (mutation == RegistryMutation::StaleSpecular) + handles.mSpecular = stale(handles.mSpecular); + if (mutation == RegistryMutation::StaleSampler) + handles.mSampler = stale(handles.mSampler); + if (mutation == RegistryMutation::StalePipeline) + handles.mPipeline = stale(handles.mPipeline); + if (mutation == RegistryMutation::StaleGBuffer0) + handles.mGBuffer0 = stale(handles.mGBuffer0); + if (mutation == RegistryMutation::StaleGBuffer1) + handles.mGBuffer1 = stale(handles.mGBuffer1); + if (mutation == RegistryMutation::StaleGBuffer2) + handles.mGBuffer2 = stale(handles.mGBuffer2); + if (mutation == RegistryMutation::StaleDepth) + handles.mDepth = stale(handles.mDepth); + + std::array images{}; + for (std::size_t image = 0; image < images.size(); ++image) + { + images[image] = { resources.mTextures[image], + PixelFormat::RGBA8Unorm, + { MATERIAL_TEXTURE_WIDTH, MATERIAL_TEXTURE_HEIGHT }, + MATERIAL_TEXTURE_MIP_LEVELS }; + } + if (mutation == RegistryMutation::WrongFormat) + images[0].mFormat = PixelFormat::RGBA16Unorm; + if (mutation == RegistryMutation::WrongExtent) + images[0].mExtent.mWidth = MATERIAL_TEXTURE_WIDTH / 2; + if (mutation == RegistryMutation::WrongMips) + --images[0].mMipLevels; + if (mutation == RegistryMutation::LiveWrongTextureFormat) + images[0].mTexture = resources.mIncompatibleTextures[0]; + if (mutation == RegistryMutation::LiveWrongTextureExtent) + images[0].mTexture = resources.mIncompatibleTextures[1]; + if (mutation == RegistryMutation::LiveWrongTextureMips) + images[0].mTexture = resources.mIncompatibleTextures[2]; + + LLRenderGLMaterial::Sampler sampler; + if (mutation == RegistryMutation::WrongSampler) + { + sampler.mMinFilter = Filter::Nearest; + sampler.mMaxAnisotropy = 1.f; + } + + ShaderProgramKey program{ MATERIAL_PROGRAM, 0 }; + if (mutation == RegistryMutation::WrongProgram) + program.mName = "deferred.material.debug"; + if (mutation == RegistryMutation::WrongVariant) + program.mVariant = 1; + + LLVertexBuffer* geometry = resources.mGeometry.get(); + if (mutation == RegistryMutation::LiveWrongLayout) + geometry = resources.mIncompatibleGeometry.get(); + if (mutation == RegistryMutation::LiveWrongIndices) + geometry = resources.mIncompatibleIndexGeometry.get(); + + LLRenderTarget* target = &resources.mTarget; + if (mutation == RegistryMutation::LiveWrongColorTarget) + target = &resources.mIncompatibleColorTarget; + if (mutation == RegistryMutation::LiveWrongDepthTarget) + target = &resources.mIncompatibleDepthTarget; + + return registry.addVertexBuffer(handles.mVertexBuffer, handles.mIndexBuffer, geometry) && + registry.addSampledImage(handles.mDiffuse, images[0]) && registry.addSampledImage(handles.mNormal, images[1]) && + registry.addSampledImage(handles.mSpecular, images[2]) && + registry.addRenderTarget({ handles.mGBuffer0, handles.mGBuffer1, handles.mGBuffer2 }, handles.mDepth, target) && + registry.addSampler(handles.mSampler, sampler) && registry.addPipeline(handles.mPipeline, std::move(program), &shader); + } + + enum class FrameMutation + { + Unchanged, + WrongLayout, + WrongImageRange, + WrongIndexRange, + WrongParameterSize + }; + + void mutateFrame(FrameSnapshot& frame, FrameMutation mutation) + { + if (mutation == FrameMutation::WrongLayout) + { + frame.mPipelines.front().mVertexBindings.front().mStride = 12; + } + else if (mutation == FrameMutation::WrongImageRange) + { + DrawIndexed& draw = std::get(frame.mPasses.front().mDraws.front()); + draw.mResources.mSampledImages.front().mRange.mMipLevelCount = MATERIAL_TEXTURE_MIP_LEVELS - 1; + } + else if (mutation == FrameMutation::WrongIndexRange) + { + DrawIndexed& draw = std::get(frame.mPasses.front().mDraws.front()); + draw.mFirstIndex = 1; + } + else if (mutation == FrameMutation::WrongParameterSize) + { + DrawIndexed& draw = std::get(frame.mPasses.front().mDraws.front()); + --draw.mResources.mParameters.front().mBytes.mSize; + } + } + + struct RejectionSpec + { + const char* mName; + RegistryMutation mRegistryMutation; + FrameMutation mFrameMutation; + }; + + constexpr std::array REJECTIONS{ + RejectionSpec{ "stale_vertex", RegistryMutation::StaleVertex, FrameMutation::Unchanged }, + RejectionSpec{ "stale_index", RegistryMutation::StaleIndex, FrameMutation::Unchanged }, + RejectionSpec{ "stale_diffuse", RegistryMutation::StaleDiffuse, FrameMutation::Unchanged }, + RejectionSpec{ "stale_normal", RegistryMutation::StaleNormal, FrameMutation::Unchanged }, + RejectionSpec{ "stale_specular", RegistryMutation::StaleSpecular, FrameMutation::Unchanged }, + RejectionSpec{ "stale_sampler", RegistryMutation::StaleSampler, FrameMutation::Unchanged }, + RejectionSpec{ "stale_pipeline", RegistryMutation::StalePipeline, FrameMutation::Unchanged }, + RejectionSpec{ "stale_gbuffer0", RegistryMutation::StaleGBuffer0, FrameMutation::Unchanged }, + RejectionSpec{ "stale_gbuffer1", RegistryMutation::StaleGBuffer1, FrameMutation::Unchanged }, + RejectionSpec{ "stale_gbuffer2", RegistryMutation::StaleGBuffer2, FrameMutation::Unchanged }, + RejectionSpec{ "stale_depth", RegistryMutation::StaleDepth, FrameMutation::Unchanged }, + RejectionSpec{ "wrong_program", RegistryMutation::WrongProgram, FrameMutation::Unchanged }, + RejectionSpec{ "wrong_variant", RegistryMutation::WrongVariant, FrameMutation::Unchanged }, + RejectionSpec{ "wrong_format", RegistryMutation::WrongFormat, FrameMutation::Unchanged }, + RejectionSpec{ "wrong_extent", RegistryMutation::WrongExtent, FrameMutation::Unchanged }, + RejectionSpec{ "wrong_layout", RegistryMutation::Unchanged, FrameMutation::WrongLayout }, + RejectionSpec{ "wrong_image_range", RegistryMutation::Unchanged, FrameMutation::WrongImageRange }, + RejectionSpec{ "wrong_index_range", RegistryMutation::Unchanged, FrameMutation::WrongIndexRange }, + RejectionSpec{ "wrong_mips", RegistryMutation::WrongMips, FrameMutation::Unchanged }, + RejectionSpec{ "wrong_sampler", RegistryMutation::WrongSampler, FrameMutation::Unchanged }, + RejectionSpec{ "live_wrong_layout", RegistryMutation::LiveWrongLayout, FrameMutation::Unchanged }, + RejectionSpec{ "live_wrong_indices", RegistryMutation::LiveWrongIndices, FrameMutation::Unchanged }, + RejectionSpec{ "live_wrong_texture_format", RegistryMutation::LiveWrongTextureFormat, FrameMutation::Unchanged }, + RejectionSpec{ "live_wrong_texture_extent", RegistryMutation::LiveWrongTextureExtent, FrameMutation::Unchanged }, + RejectionSpec{ "live_wrong_texture_mips", RegistryMutation::LiveWrongTextureMips, FrameMutation::Unchanged }, + RejectionSpec{ "live_wrong_color_target", RegistryMutation::LiveWrongColorTarget, FrameMutation::Unchanged }, + RejectionSpec{ "live_wrong_depth_target", RegistryMutation::LiveWrongDepthTarget, FrameMutation::Unchanged }, + RejectionSpec{ "wrong_parameter_size", RegistryMutation::Unchanged, FrameMutation::WrongParameterSize } + }; + + template + void comparePlane(const std::array& reference, + const std::array& candidate, + const char* plane, + U64& mismatches, + double& max_abs_delta, + std::string& first_failure) + { + constexpr double denominator = static_cast(std::numeric_limits::max()); + for (std::size_t component = 0; component < Size; ++component) + { + if (reference[component] == candidate[component]) + { + continue; + } + ++mismatches; + max_abs_delta = + std::max(max_abs_delta, + std::abs(static_cast(reference[component]) - static_cast(candidate[component])) / denominator); + if (first_failure.empty()) + { + std::ostringstream failure; + failure << "parity plane=" << plane << " pixel=" << component / MATERIAL_DIAGNOSTIC_CHANNELS + << " channel=" << component % MATERIAL_DIAGNOSTIC_CHANNELS << " legacy=" << static_cast(reference[component]) + << " contract=" << static_cast(candidate[component]); + first_failure = failure.str(); + } + } + } + + void compareDepth(const MaterialReadback& reference, + const MaterialReadback& candidate, + U64& mismatches, + double& max_abs_delta, + std::string& first_failure) + { + for (std::size_t pixel = 0; pixel < reference.mDepth.size(); ++pixel) + { + if (reference.mDepth[pixel] == candidate.mDepth[pixel]) + { + continue; + } + ++mismatches; + max_abs_delta = std::max(max_abs_delta, + std::abs(static_cast(reference.mDepth[pixel]) - static_cast(candidate.mDepth[pixel])) / + DEPTH24_MAX); + if (first_failure.empty()) + { + std::ostringstream failure; + failure << "parity plane=depth pixel=" << pixel << " legacy=" << reference.mDepth[pixel] + << " contract=" << candidate.mDepth[pixel]; + first_failure = failure.str(); + } + } + } + + template + bool hasDistinctWrittenPixels(const std::array& values, + const std::array& sentinel, + const std::array& depth, + const std::array& depth_sentinel) + { + static_assert(Size % MATERIAL_DIAGNOSTIC_CHANNELS == 0); + static_assert(Size / MATERIAL_DIAGNOSTIC_CHANNELS == DepthSize); + std::size_t first_pixel = DepthSize; + bool changed_from_sentinel = false; + bool distinct = false; + for (std::size_t pixel = 0; pixel < DepthSize; ++pixel) + { + if (depth[pixel] == depth_sentinel[pixel]) + { + continue; + } + if (first_pixel == DepthSize) + { + first_pixel = pixel; + } + for (std::size_t channel = 0; channel < MATERIAL_DIAGNOSTIC_CHANNELS; ++channel) + { + const std::size_t component = pixel * MATERIAL_DIAGNOSTIC_CHANNELS + channel; + const std::size_t first_component = first_pixel * MATERIAL_DIAGNOSTIC_CHANNELS + channel; + changed_from_sentinel = changed_from_sentinel || values[component] != sentinel[component]; + distinct = distinct || values[component] != values[first_component]; + } + } + return changed_from_sentinel && distinct; + } + + struct DepthGate + { + U32 mPasses = 0; + U32 mFailures = 0; + U32 mMirrorClippedPasses = 0; + bool mValid = false; + }; + + DepthGate verifyDepthGate(const MaterialFixture& fixture, const MaterialReadback& readback) + { + struct Vertex + { + double mX; + double mY; + double mZ; + double mReciprocalW; + std::array mViewOverW; + }; + std::array positions{}; + std::memcpy(positions.data(), fixture.mVertexBytes.data() + MATERIAL_POSITION_OFFSET, sizeof(positions)); + std::array vertices{}; + auto transform = [](const std::array& matrix, const float* value) + { + std::array result{}; + for (std::size_t row = 0; row < result.size(); ++row) + { + for (std::size_t column = 0; column < result.size(); ++column) + { + result[row] += static_cast(matrix[column * 4 + row]) * value[column]; + } + } + return result; + }; + for (std::size_t vertex = 0; vertex < vertices.size(); ++vertex) + { + const float* position = positions.data() + vertex * 4; + const std::array clip = transform(fixture.mParameters.mModelviewProjectionMatrix, position); + const std::array view = transform(fixture.mParameters.mModelviewMatrix, position); + const double reciprocal_w = 1.0 / clip[3]; + vertices[vertex] = { (clip[0] * reciprocal_w * 0.5 + 0.5) * MATERIAL_FRAME_WIDTH, + (clip[1] * reciprocal_w * 0.5 + 0.5) * MATERIAL_FRAME_HEIGHT, + clip[2] * reciprocal_w * 0.5 + 0.5, + reciprocal_w, + { view[0] * reciprocal_w, view[1] * reciprocal_w, view[2] * reciprocal_w } }; + } + + auto cross = [](const Vertex& first, const Vertex& second, double x, double y) + { + return (second.mX - first.mX) * (y - first.mY) - (second.mY - first.mY) * (x - first.mX); + }; + + DepthGate gate; + for (U32 y = 0; y < MATERIAL_FRAME_HEIGHT; ++y) + { + for (U32 x = 0; x < MATERIAL_FRAME_WIDTH; ++x) + { + const double sample_x = x + 0.5; + const double sample_y = y + 0.5; + bool covered = false; + double fragment_depth = 0.0; + for (std::size_t triangle = 0; triangle < fixture.mIndices.size(); triangle += 3) + { + const Vertex& a = vertices[fixture.mIndices[triangle]]; + const Vertex& b = vertices[fixture.mIndices[triangle + 1]]; + const Vertex& c = vertices[fixture.mIndices[triangle + 2]]; + const double area = cross(a, b, c.mX, c.mY); + const double wa = cross(b, c, sample_x, sample_y) / area; + const double wb = cross(c, a, sample_x, sample_y) / area; + const double wc = 1.0 - wa - wb; + if (std::min({ wa, wb, wc }) <= 0.08) + { + continue; + } + fragment_depth = wa * a.mZ + wb * b.mZ + wc * c.mZ; + const double reciprocal_w = wa * a.mReciprocalW + wb * b.mReciprocalW + wc * c.mReciprocalW; + std::array view_position{}; + for (std::size_t component = 0; component < view_position.size(); ++component) + { + view_position[component] = + (wa * a.mViewOverW[component] + wb * b.mViewOverW[component] + wc * c.mViewOverW[component]) / reciprocal_w; + } + const auto& clip_plane = fixture.mParameters.mClipPlane; + const double clip_distance = view_position[0] * clip_plane[0] + view_position[1] * clip_plane[1] + + view_position[2] * clip_plane[2] + clip_plane[3]; + if (fixture.mParameters.mMirror > 0.f && clip_distance < 0.0) + { + const std::size_t pixel = y * MATERIAL_FRAME_WIDTH + x; + const double loaded_depth = materialDepth24(fixture.mDepth24[pixel]); + if (std::abs(fragment_depth - loaded_depth) >= 0.02 && fragment_depth <= loaded_depth) + { + if (readback.mDepth[pixel] != fixture.mDepth24[pixel]) + { + return gate; + } + ++gate.mMirrorClippedPasses; + } + covered = false; + break; + } + covered = true; + break; + } + if (!covered) + { + continue; + } + + const std::size_t pixel = y * MATERIAL_FRAME_WIDTH + x; + const double loaded_depth = materialDepth24(fixture.mDepth24[pixel]); + if (std::abs(fragment_depth - loaded_depth) < 0.02) + { + continue; + } + const bool expected_pass = fragment_depth <= loaded_depth; + const bool depth_changed = readback.mDepth[pixel] != fixture.mDepth24[pixel]; + if (expected_pass != depth_changed) + { + return gate; + } + if (expected_pass) + { + ++gate.mPasses; + } + else + { + ++gate.mFailures; + } + } + } + gate.mValid = gate.mPasses > 0 && gate.mFailures > 0 && gate.mMirrorClippedPasses > 0; + return gate; + } + + bool nontrivialOutput(const MaterialFixture& fixture, const MaterialReadback& readback, DepthGate& depth_gate) + { + depth_gate = verifyDepthGate(fixture, readback); + return readback.mGBuffer0 != fixture.mGBuffer0SentinelRGBA8 && readback.mGBuffer1 != fixture.mGBuffer1SentinelRGBA8 && + readback.mGBuffer2 != fixture.mGBuffer2SentinelRGBA16 && + hasDistinctWrittenPixels(readback.mGBuffer0, fixture.mGBuffer0SentinelRGBA8, readback.mDepth, fixture.mDepth24) && + hasDistinctWrittenPixels(readback.mGBuffer1, fixture.mGBuffer1SentinelRGBA8, readback.mDepth, fixture.mDepth24) && + hasDistinctWrittenPixels(readback.mGBuffer2, fixture.mGBuffer2SentinelRGBA16, readback.mDepth, fixture.mDepth24) && + depth_gate.mValid; + } + +} // namespace + +bool run() +{ + const MaterialFixture fixture = makeMaterialFixture(); + const MaterialCase material_case = makeMaterialCase(); + LLGLSLShader& shader = gDeferredMaterialProgram[12]; + + MaterialResources legacy; + MaterialResources contract; + if (!initializeResources(legacy, fixture) || !initializeResources(contract, fixture) || !seedTarget(legacy.mTarget, fixture) || + !seedTarget(contract.mTarget, fixture)) + { + return emitFailure("fixture_setup"); + } + + LLRenderGLMaterial::Registry registry; + if (!registerResources(registry, material_case.mInputs, contract, shader, RegistryMutation::Unchanged)) + { + return emitFailure("registry_setup"); + } + + const SubmissionResult legacy_submission = + submitPoisoned(contract, shader, [&]() { return submitLegacy(legacy, shader, material_case.mFrame); }); + const SubmissionResult contract_submission = + submitPoisoned(legacy, shader, [&]() { return LLRenderGLMaterial::execute(material_case.mFrame, registry); }); + if (!legacy_submission.mPoisoned || !legacy_submission.mAccepted || legacy_submission.mError != GL_NO_ERROR || + !contract_submission.mPoisoned || !contract_submission.mAccepted || contract_submission.mError != GL_NO_ERROR) + { + std::ostringstream failure; + failure << "valid_submission" + << " legacy_poisoned=" << legacy_submission.mPoisoned << " legacy_accepted=" << legacy_submission.mAccepted + << " legacy_gl_error=0x" << std::hex << legacy_submission.mError << " contract_poisoned=" << std::dec + << contract_submission.mPoisoned << " contract_accepted=" << contract_submission.mAccepted << " contract_gl_error=0x" + << std::hex << contract_submission.mError; + return emitFailure(failure.str()); + } + + MaterialReadback legacy_readback; + MaterialReadback contract_readback; + if (!readTarget(legacy.mTarget, legacy_readback) || !readTarget(contract.mTarget, contract_readback)) + { + return emitFailure("valid_readback"); + } + + U64 mismatches = 0; + double max_abs_delta = 0.0; + std::string first_failure; + comparePlane(legacy_readback.mGBuffer0, contract_readback.mGBuffer0, "gbuffer0", mismatches, max_abs_delta, first_failure); + comparePlane(legacy_readback.mGBuffer1, contract_readback.mGBuffer1, "gbuffer1", mismatches, max_abs_delta, first_failure); + comparePlane(legacy_readback.mGBuffer2, contract_readback.mGBuffer2, "gbuffer2", mismatches, max_abs_delta, first_failure); + compareDepth(legacy_readback, contract_readback, mismatches, max_abs_delta, first_failure); + + DepthGate depth_gate; + const bool nontrivial = nontrivialOutput(fixture, legacy_readback, depth_gate); + if (!nontrivial && first_failure.empty()) + { + first_failure = "nontrivial_output_gate"; + } + + U32 rejection_failures = 0; + for (const RejectionSpec& rejection : REJECTIONS) + { + LLRenderTarget* rejection_target = &contract.mTarget; + if (rejection.mRegistryMutation == RegistryMutation::LiveWrongColorTarget) + { + rejection_target = &contract.mIncompatibleColorTarget; + } + else if (rejection.mRegistryMutation == RegistryMutation::LiveWrongDepthTarget) + { + rejection_target = &contract.mIncompatibleDepthTarget; + } + + if (!seedTarget(*rejection_target, fixture)) + { + ++rejection_failures; + if (first_failure.empty()) + first_failure = std::string("rejection_seed ") + rejection.mName; + continue; + } + + MaterialReadback before; + MaterialReadback after; + FrameSnapshot rejected_frame = material_case.mFrame; + mutateFrame(rejected_frame, rejection.mFrameMutation); + LLRenderGLMaterial::Registry rejected_registry; + const bool registered = registerResources(rejected_registry, material_case.mInputs, contract, shader, rejection.mRegistryMutation); + const bool baseline_read = readTarget(*rejection_target, before); + SubmissionResult submission; + if (registered && baseline_read) + { + submission = submitPoisoned(legacy, shader, [&]() { return LLRenderGLMaterial::execute(rejected_frame, rejected_registry); }); + } + const bool after_read = readTarget(*rejection_target, after); + if (!registered || !baseline_read || !submission.mPoisoned || submission.mAccepted || submission.mError != GL_NO_ERROR || + !after_read || before != after) + { + ++rejection_failures; + if (first_failure.empty()) + first_failure = std::string("rejection ") + rejection.mName; + } + } + + bool success = mismatches == 0 && nontrivial && rejection_failures == 0; + const std::string artifact_path = gSavedSettings.getString("RenderMaterialArtifactPath"); + bool artifact_written = false; + if (success && !artifact_path.empty()) + { + MaterialArtifact artifact = artifactFrom(legacy_readback); + std::string artifact_error; + artifact_written = + validateMaterialArtifact(artifact, &artifact_error) && writeMaterialArtifact(artifact_path, artifact, &artifact_error); + if (!artifact_written) + { + success = false; + first_failure = "artifact_write"; + } + } + + std::ostringstream result; + result << "MATERIAL_CONTRACT_PARITY result=" << (success ? "pass" : "fail") << " case=nonrigged_normspec_indexed" + << " shader_index=12 shader_class=3" + << " components=" << (MATERIAL_DIAGNOSTIC_COLOR_COMPONENT_COUNT * 3 + MATERIAL_DIAGNOSTIC_DEPTH_COMPONENT_COUNT) + << " mismatches=" << mismatches << " max_abs_delta=" << max_abs_delta << " depth_passes=" << depth_gate.mPasses + << " depth_failures=" << depth_gate.mFailures << " mirror_clipped_passes=" << depth_gate.mMirrorClippedPasses + << " rejection_cases=" << REJECTIONS.size() << " rejection_failures=" << rejection_failures << " artifact=" + << (artifact_path.empty() ? "disabled" + : artifact_written ? "written" + : "failed"); + if (!first_failure.empty()) + { + result << " first_failure={" << first_failure << '}'; + } + std::cout << result.str() << std::endl; + LL_INFOS("RenderContractParity") << result.str() << LL_ENDL; + return success; +} + +} // namespace LLMaterialParity diff --git a/indra/newview/llmaterialparity.h b/indra/newview/llmaterialparity.h new file mode 100644 index 00000000000..b04a86c764a --- /dev/null +++ b/indra/newview/llmaterialparity.h @@ -0,0 +1,26 @@ +/** + * @file llmaterialparity.h + * @brief Account-free parity harness for the indexed deferred material draw. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLMATERIALPARITY_H +#define LL_LLMATERIALPARITY_H + +namespace LLMaterialParity +{ + +bool run(); + +} + +#endif diff --git a/indra/newview/llmeshrepository.cpp b/indra/newview/llmeshrepository.cpp index 2b8ecbb667e..402da98470b 100644 --- a/indra/newview/llmeshrepository.cpp +++ b/indra/newview/llmeshrepository.cpp @@ -5113,6 +5113,17 @@ S32 LLMeshRepository::getMeshSize(const LLUUID& mesh_id, S32 lod) const return -1; } +void LLMeshRepository::getLoadingMeshCounts(U32& lod_count, U32& skin_count) +{ + LLMutexLock lock(mMeshMutex); + lod_count = 0; + for (const mesh_load_map& loading_meshes : mLoadingMeshes) + { + lod_count += static_cast(loading_meshes.size()); + } + skin_count = static_cast(mLoadingSkins.size()); +} + void LLMeshUploadThread::decomposeMeshMatrix(LLMatrix4& transformation, LLVector3& result_pos, LLQuaternion& result_rot, diff --git a/indra/newview/llmeshrepository.h b/indra/newview/llmeshrepository.h index 28b7a2d01f0..ef36feb382b 100644 --- a/indra/newview/llmeshrepository.h +++ b/indra/newview/llmeshrepository.h @@ -904,6 +904,7 @@ class LLMeshRepository LLHandle upload_observer = (LLHandle())); S32 getMeshSize(const LLUUID& mesh_id, S32 lod) const; + void getLoadingMeshCounts(U32& lod_count, U32& skin_count); // Quiescent timer management, main thread only. static void metricsStart(); diff --git a/indra/newview/llspatialpartition.cpp b/indra/newview/llspatialpartition.cpp index c2ac07af881..85c14948700 100644 --- a/indra/newview/llspatialpartition.cpp +++ b/indra/newview/llspatialpartition.cpp @@ -3816,26 +3816,6 @@ LLDrawable* LLSpatialGroup::lineSegmentIntersect(const LLVector4a& start, const return drawable; } -LLDrawInfo::LLDrawInfo(U16 start, U16 end, U32 count, U32 offset, - LLViewerTexture* texture, LLVertexBuffer* buffer, - bool fullbright, U8 bump) -: mVertexBuffer(buffer), - mTexture(texture), - mStart(start), - mEnd(end), - mCount(count), - mOffset(offset), - mFullbright(fullbright), - mBump(bump), - mBlendFuncSrc(LLRender::BF_SOURCE_ALPHA), - mBlendFuncDst(LLRender::BF_ONE_MINUS_SOURCE_ALPHA), - mHasGlow(false), - mEnvIntensity(0.0f), - mAlphaMaskCutoff(0.5f) -{ - mVertexBuffer->validateRange(mStart, mEnd, mCount, mOffset); -} - LLDrawInfo::~LLDrawInfo() { if (gDebugGL) @@ -3844,30 +3824,6 @@ LLDrawInfo::~LLDrawInfo() } } -LLColor4U LLDrawInfo::getDebugColor() const -{ - LLColor4U color; - - LLCRC hash; - hash.update((U8*)this + sizeof(S32), sizeof(LLDrawInfo) - sizeof(S32)); - - *((U32*) color.mV) = hash.getCRC(); - - color.mV[3] = 200; - - return color; -} - -void LLDrawInfo::validate() -{ - mVertexBuffer->validateRange(mStart, mEnd, mCount, mOffset); -} - -U64 LLDrawInfo::getSkinHash() -{ - return mSkinInfo ? mSkinInfo->mHash : 0; -} - LLCullResult::LLCullResult() { mVisibleGroupsAllocated = 0; @@ -4168,4 +4124,3 @@ void LLCullResult::assertDrawMapsEmpty() } } } - diff --git a/indra/newview/llstatslistener.cpp b/indra/newview/llstatslistener.cpp index b0f8d542312..cfacdb6a288 100644 --- a/indra/newview/llstatslistener.cpp +++ b/indra/newview/llstatslistener.cpp @@ -31,6 +31,45 @@ #include "llimagegl.h" #include "llviewerstats.h" +#if defined(LL_RENDER_BENCHMARK) +#include "llagent.h" +#include "llagentcamera.h" +#include "llagentwearables.h" +#include "llappviewer.h" +#include "llappearancemgr.h" +#include "llbenchmarkappearance.h" +#include "llcircuit.h" +#include "llfloater.h" +#include "llfloaterreg.h" +#include "llfocusmgr.h" +#include "llgl.h" +#include "llmeshrepository.h" +#include "llmodaldialog.h" +#include "llpanel.h" +#include "llrender.h" +#include "llsdutil_math.h" +#include "llslurl.h" +#include "llstartup.h" +#include "lltexturefetch.h" +#include "lltoast.h" +#include "llviewercontrol.h" +#include "llviewercamera.h" +#include "llviewerobjectlist.h" +#include "llviewerparcelmgr.h" +#include "llviewerregion.h" +#include "llviewershadermgr.h" +#include "llviewerinventory.h" +#include "llviewertexturelist.h" +#include "llviewerwindow.h" +#include "llvoavatar.h" +#include "llvoavatarself.h" +#include "llwindow.h" +#include "message.h" +#include "pipeline.h" + +#include +#endif + namespace { template @@ -65,6 +104,365 @@ void setPeriodArray(LLSD& out, out[key] = collectPeriodArray(recording, num_periods, stat, extractor); } } + +#if defined(LL_RENDER_BENCHMARK) +template +void setFrameSample(LLSD& frame, + const char* key, + LLTrace::Recording& period, + const STAT& stat) +{ + if (period.hasValue(stat)) + { + frame[key] = (F64)period.getLastValue(stat); + } +} + +void setFrameBlockTime(LLSD& frame, + const char* key, + LLTrace::Recording& period, + const LLTrace::BlockTimerStatHandle& stat) +{ + if (period.hasValue(stat)) + { + frame[key] = F64Milliseconds(period.getSum(stat)).value(); + } +} + +void setFrameCountSum(LLSD& frame, + const char* key, + LLTrace::Recording& period, + const LLTrace::CountStatHandle<>& stat) +{ + if (period.hasValue(stat)) + { + frame[key] = (F64)period.getSum(stat); + } +} + +LLSD collectRendererFrames(LLTrace::PeriodicRecording& recording, size_t num_periods) +{ + LLSD frames = LLSD::emptyArray(); + + // Emit oldest-to-newest so cumulative resource counters can be differenced + // directly. FRAME_NUMBER lets polling clients de-duplicate the rolling + // recording window without relying on array position. + for (size_t offset = num_periods; offset > 0; --offset) + { + LLTrace::Recording& period = recording.getPrevRecording(offset); + if (!period.hasValue(LLStatViewer::FRAME_NUMBER)) + { + continue; + } + + LLSD frame; + setFrameSample(frame, "frame_number", period, LLStatViewer::FRAME_NUMBER); + setFrameSample(frame, "frame_time_ms", period, LLStatViewer::FRAMETIME); + setFrameSample(frame, "do_frame_time_us", period, LLStatViewer::DOFRAME_TIME_US); + setFrameSample(frame, "sim_ping_ms", period, LLStatViewer::SIM_PING); + setFrameSample(frame, "visible_avatars", period, LLStatViewer::VISIBLE_AVATARS); + setFrameSample(frame, "active_objects", period, LLStatViewer::NUM_ACTIVE_OBJECTS); + setFrameCountSum(frame, "new_objects", period, LLStatViewer::NUM_NEW_OBJECTS); + setFrameCountSum(frame, "camera_translation_m", period, *LLViewerCamera::getVelocityStat()); + setFrameCountSum(frame, "camera_rotation_rad", period, *LLViewerCamera::getAngularVelocityStat()); + + setFrameBlockTime(frame, "geometry_create_ms", period, LLStatViewer::RENDER_GEOMETRY_CREATE); + setFrameBlockTime(frame, "partition_ms", period, LLStatViewer::RENDER_PARTITION); + setFrameBlockTime(frame, "geometry_update_ms", period, LLStatViewer::RENDER_GEOMETRY_UPDATE); + setFrameBlockTime(frame, "cull_ms", period, LLStatViewer::RENDER_CULL); + setFrameBlockTime(frame, "shadows_ms", period, LLStatViewer::RENDER_SHADOWS); + setFrameBlockTime(frame, "texture_work_ms", period, LLStatViewer::RENDER_TEXTURE_WORK); + setFrameBlockTime(frame, "state_sort_ms", period, LLStatViewer::RENDER_STATE_SORT); + setFrameBlockTime(frame, "rebuild_ms", period, LLStatViewer::RENDER_REBUILD); + setFrameBlockTime(frame, "submission_ms", period, LLStatViewer::RENDER_SUBMISSION); + setFrameBlockTime(frame, "lighting_ms", period, LLStatViewer::RENDER_LIGHTING); + setFrameBlockTime(frame, "ui_ms", period, LLStatViewer::RENDER_UI); + setFrameBlockTime(frame, "swap_ms", period, LLStatViewer::RENDER_SWAP); + setFrameBlockTime(frame, "idle_ms", period, LLStatViewer::RENDER_IDLE); + + if (period.hasValue(LLPipeline::sStatBatchSize)) + { + frame["draw_calls"] = (LLSD::Integer)period.getSampleCount(LLPipeline::sStatBatchSize); + frame["batch_size_min"] = (F64)period.getMin(LLPipeline::sStatBatchSize); + frame["batch_size_max"] = (F64)period.getMax(LLPipeline::sStatBatchSize); + frame["batch_size_mean"] = (F64)period.getMean(LLPipeline::sStatBatchSize); + } + if (period.hasValue(LLStatViewer::TRIANGLES_DRAWN)) + { + frame["ktriangles"] = (F64)period.getSum(LLStatViewer::TRIANGLES_DRAWN); + } + + setFrameSample(frame, "texture_upload_count_total", period, LLStatViewer::TEXTURE_UPLOAD_COUNT); + setFrameSample(frame, "texture_upload_bytes_total", period, LLStatViewer::TEXTURE_UPLOAD_BYTES); + setFrameSample(frame, "texture_readback_count_total", period, LLStatViewer::TEXTURE_READBACK_COUNT); + setFrameSample(frame, "texture_readback_time_us_total", period, LLStatViewer::TEXTURE_READBACK_TIME_US); + setFrameSample(frame, "texture_wait_count_total", period, LLStatViewer::TEXTURE_WAIT_COUNT); + setFrameSample(frame, "texture_wait_time_us_total", period, LLStatViewer::TEXTURE_WAIT_TIME_US); + setFrameSample(frame, "shader_compile_count_total", period, LLStatViewer::SHADER_COMPILE_COUNT); + setFrameSample(frame, "shader_compile_time_us_total", period, LLStatViewer::SHADER_COMPILE_TIME_US); + setFrameSample(frame, "shader_bind_count_total", period, LLStatViewer::SHADER_BIND_COUNT); + + frames.append(frame); + } + return frames; +} + +LLSD getRendererContext() +{ + LLSD viewer_info = LLAppViewer::instance()->getViewerInfo(); + LLSD context; + + context["viewer_version"] = viewer_info["VIEWER_VERSION_STR"]; + context["viewer_channel"] = viewer_info["CHANNEL"]; + context["build_type"] = viewer_info.has("BUILD_CONFIG") ? viewer_info["BUILD_CONFIG"] : LLSD("Release"); + context["os"] = viewer_info["OS_VERSION"]; + context["cpu"] = viewer_info["CPU"]; + context["logical_core_count"] = (LLSD::Integer)std::thread::hardware_concurrency(); + context["gpu_vendor"] = viewer_info["GRAPHICS_CARD_VENDOR"]; + context["gpu"] = viewer_info["GRAPHICS_CARD"]; + context["driver"] = gGLManager.mDriverVersionVendorString; + const std::string opengl_version = viewer_info["OPENGL_VERSION"].asString(); + context["opengl_version"] = opengl_version; + if (opengl_version.find("Core Profile") != std::string::npos) + { + context["opengl_profile"] = "core"; + } + else if (opengl_version.find("Compatibility Profile") != std::string::npos) + { + context["opengl_profile"] = "compatibility"; + } + else + { + context["opengl_profile"] = LLRender::sGLCoreProfile ? "core" : "compatibility"; + } + LLCoordWindow backing_size; + LLCoordWindow logical_size; + F32 backing_scale_x = 0.f; + F32 backing_scale_y = 0.f; + LLWindow* window = gViewerWindow ? gViewerWindow->getWindow() : nullptr; + if (window) + { + window->getSize(&backing_size); + window->getNativeContentSize(&logical_size); + window->getBackingScale(backing_scale_x, backing_scale_y); + } + context["width"] = backing_size.mX; + context["height"] = backing_size.mY; + context["backing_width"] = backing_size.mX; + context["backing_height"] = backing_size.mY; + context["logical_width"] = logical_size.mX; + context["logical_height"] = logical_size.mY; + context["backing_scale_x"] = backing_scale_x; + context["backing_scale_y"] = backing_scale_y; + context["configured_ui_scale"] = gSavedSettings.getF32("UIScaleFactor"); + const LLVector2 display_scale = gViewerWindow ? gViewerWindow->getDisplayScale() : LLVector2::zero; + context["effective_display_scale_x"] = display_scale.mV[VX]; + context["effective_display_scale_y"] = display_scale.mV[VY]; + context["gpu_vram_mb"] = (LLSD::Integer)gGLManager.mVRAM; + context["shader_level"] = LLViewerShaderMgr::instance()->getShaderLevel(LLViewerShaderMgr::SHADER_DEFERRED); + + LLSD limits; + limits["max_texture_size"] = gGLManager.mGLMaxTextureSize; + limits["max_texture_image_units"] = gGLManager.mNumTextureImageUnits; + limits["max_samples"] = gGLManager.mMaxSamples; + limits["max_uniform_block_size"] = gGLManager.mMaxUniformBlockSize; + context["gl_limits"] = limits; + + LLSD extensions = LLSD::emptyArray(); + for (const std::string& extension : gGLManager.mGLExtensions) + { + extensions.append(extension); + } + context["gl_extensions"] = extensions; + + std::string renderer = context["gpu"].asString(); + LLStringUtil::toLower(renderer); + context["detected_backend"] = renderer.find("zink") == std::string::npos ? "native-gl" : "zink"; + + LLSD settings; + settings["AutoTuneFPS"] = gSavedSettings.getBOOL("AutoTuneFPS"); + settings["RenderAvatarMaxNonImpostors"] = (LLSD::Integer)gSavedSettings.getU32("RenderAvatarMaxNonImpostors"); + settings["RenderVSyncEnable"] = gSavedSettings.getBOOL("RenderVSyncEnable"); + settings["RenderDeferred"] = gSavedSettings.getBOOL("RenderDeferred"); + settings["RenderShadowDetail"] = gSavedSettings.getS32("RenderShadowDetail"); + settings["RenderReflectionProbeDetail"] = gSavedSettings.getS32("RenderReflectionProbeDetail"); + settings["RenderReflectionsEnabled"] = gSavedSettings.getBOOL("RenderReflectionsEnabled"); + settings["RenderFarClip"] = gSavedSettings.getF32("RenderFarClip"); + settings["RenderVolumeLODFactor"] = gSavedSettings.getF32("RenderVolumeLODFactor"); + settings["RenderQualityPerformance"] = (LLSD::Integer)gSavedSettings.getU32("RenderQualityPerformance"); + settings["RenderGLContextCoreProfile"] = LLRender::sGLCoreProfile; + settings["RenderBenchmarkUIScale"] = gSavedSettings.getF32("RenderBenchmarkUIScale"); + settings["RenderHiDPI"] = gSavedSettings.getBOOL("RenderHiDPI"); + settings["WindowHeight"] = (LLSD::Integer)gSavedSettings.getU32("WindowHeight"); + settings["WindowMaximized"] = gSavedSettings.getBOOL("WindowMaximized"); + settings["WindowWidth"] = (LLSD::Integer)gSavedSettings.getU32("WindowWidth"); + settings["YieldTime"] = gSavedSettings.getS32("YieldTime"); + context["effective_settings"] = settings; + context["feature_flags"] = settings; + + return context; +} + +bool destinationMatchesStartLocation() +{ + const LLSLURL& destination = LLStartUp::getStartSLURL(); + LLViewerRegion* region = gAgent.getRegion(); + if (!destination.isValid() || destination.getType() != LLSLURL::LOCATION || !region) + { + return false; + } + + const LLVector3 position = gAgent.getPositionAgent(); + const LLVector3 destination_position = destination.getPosition(); + const F32 delta_x = position.mV[VX] - destination_position.mV[VX]; + const F32 delta_y = position.mV[VY] - destination_position.mV[VY]; + constexpr F32 destination_slop_m = 2.f; + return destination.getRegion() == region->getName() + && delta_x * delta_x <= destination_slop_m * destination_slop_m + && delta_y * delta_y <= destination_slop_m * destination_slop_m; +} + +LLSD getRendererSceneState() +{ + LLSD state; + state["destination_matches"] = destinationMatchesStartLocation(); + state["teleport_in_progress"] = LLViewerParcelMgr::getInstance()->getTeleportInProgress(); + state["progress_visible"] = gViewerWindow && gViewerWindow->getShowProgress(); + + LLViewerCamera* camera = LLViewerCamera::getInstance(); + LLSD view; + view["camera_offset"] = ll_sd_from_vector3(camera->getOrigin() - gAgent.getPositionAgent()); + view["camera_at"] = ll_sd_from_vector3(camera->getAtAxis()); + view["camera_left"] = ll_sd_from_vector3(camera->getLeftAxis()); + view["camera_up"] = ll_sd_from_vector3(camera->getUpAxis()); + view["agent_at"] = ll_sd_from_vector3(gAgent.getAtAxis()); + view["field_of_view"] = camera->getView(); + view["mode"] = (LLSD::Integer)gAgentCamera.getCameraMode(); + state["view"] = view; + state["camera_animating"] = gAgentCamera.getCameraAnimating(); + state["agent_distance_traveled_total"] = gAgent.getDistanceTraveled(); + state["agent_speed_mps"] = gAgent.getVelocity().length(); + + state["app_focused"] = gFocusMgr.getAppHasFocus(); + state["frame_count_total"] = (F64)gFrameCount; + state["foreground_frame_count_total"] = (F64)gForegroundFrameCount; + state["modal_dialog_count"] = LLModalDialog::activeCount(); + state["alert_toast_visible"] = LLNotificationsUI::LLToast::isAlertToastShown(); + state["welcome_pack_visible"] = LLFloaterReg::instanceVisible("avatar_welcome_pack"); + state["closeable_floaters_closed"] = !gFloaterView || gFloaterView->allChildrenClosed(); + LLView* hint_holder = gViewerWindow ? gViewerWindow->getHintHolder() : nullptr; + state["hint_visible"] = hint_holder && hint_holder->getVisible() && hint_holder->getChildCount() > 0; + + LLTextureFetch* texture_fetch = LLAppViewer::getTextureFetch(); + state["texture_fetch_requests"] = texture_fetch ? texture_fetch->getNumRequests() : -1; + state["texture_http_requests"] = texture_fetch ? texture_fetch->getNumHTTPRequests() : -1; + state["texture_create_queue"] = (LLSD::Integer)gTextureList.mCreateTextureList.size(); + state["texture_fast_cache"] = (LLSD::Integer)gTextureList.mFastCacheList.size(); + state["texture_upload_count_total"] = (F64)LLImageGL::getTextureUploadCount(); + U32 loading_lods = 0; + U32 loading_skins = 0; + gMeshRepo.getLoadingMeshCounts(loading_lods, loading_skins); + state["mesh_lod_unresolved"] = (LLSD::Integer)loading_lods; + state["mesh_skin_unresolved"] = (LLSD::Integer)loading_skins; + state["self_avatar_loaded"] = isAgentAvatarValid() && gAgentAvatarp->isFullyLoaded(); + state["visible_avatars"] = LLVOAvatar::sNumVisibleAvatars; + state["active_objects"] = gObjectList.getNumActiveObjects(); + + LLCircuitData* circuit = nullptr; + if (gMessageSystem && gAgent.getRegion()) + { + circuit = gMessageSystem->mCircuitInfo.findCircuit(gAgent.getRegion()->getHost()); + } + state["circuit_present"] = circuit != nullptr; + state["circuit_alive"] = circuit && circuit->isAlive(); + state["circuit_blocked"] = circuit && circuit->isBlocked(); + state["pings_in_transit"] = circuit ? circuit->getPingsInTransit() : -1; + state["packets_in_total"] = circuit ? (F64)circuit->getPacketsIn() : 0.0; + state["packets_lost_total"] = circuit ? (F64)circuit->getPacketsLost() : 0.0; + return state; +} + +constexpr std::array REQUIRED_WEARABLE_TYPES = { + LLWearableType::WT_SHAPE, + LLWearableType::WT_SKIN, + LLWearableType::WT_HAIR, + LLWearableType::WT_EYES, +}; + +constexpr std::array REQUIRED_WEARABLE_NAMES = { + "shape", + "skin", + "hair", + "eyes", +}; + +LLSD requiredPartsToLLSD(const LLBenchmarkAppearance::RequiredParts& parts) +{ + LLSD result; + for (size_t index = 0; index < parts.size(); ++index) + { + result[REQUIRED_WEARABLE_NAMES[index]] = parts[index]; + } + return result; +} + +LLSD getRendererAppearanceState() +{ + LLBenchmarkAppearance::Facts facts; + facts.avatar_valid = isAgentAvatarValid(); + + const LLUUID cof_id = LLAppearanceMgr::instance().getCOF(); + facts.cof_present = !cof_id.isNull() && gInventory.getCategory(cof_id); + facts.cof_complete = facts.cof_present && gInventory.isCategoryComplete(cof_id); + facts.cof_change_in_progress = gAgentWearables.isCOFChangeInProgress(); + + if (facts.cof_present) + { + LLInventoryModel::cat_array_t* categories = nullptr; + LLInventoryModel::item_array_t* items = nullptr; + gInventory.getDirectDescendentsOf(cof_id, categories, items); + if (items) + { + for (const LLPointer& item : *items) + { + const LLViewerInventoryItem* linked_item = + item && item->getIsLinkType() ? item->getLinkedItem() : nullptr; + if (!linked_item || !linked_item->isWearableType()) + { + continue; + } + const LLWearableType::EType type = linked_item->getWearableType(); + for (size_t index = 0; index < REQUIRED_WEARABLE_TYPES.size(); ++index) + { + if (type == REQUIRED_WEARABLE_TYPES[index]) + { + facts.required_links_resolved[index] = true; + break; + } + } + } + } + } + + for (size_t index = 0; index < REQUIRED_WEARABLE_TYPES.size(); ++index) + { + facts.required_wearables_delivered[index] = + gAgentWearables.getWearableCount(REQUIRED_WEARABLE_TYPES[index]) > 0; + } + facts.avatar_loaded = facts.avatar_valid && gAgentAvatarp->isFullyLoaded(); + + LLSD result; + result["classification"] = LLBenchmarkAppearance::classify(facts); + result["avatar_valid"] = facts.avatar_valid; + result["cof_present"] = facts.cof_present; + result["cof_complete"] = facts.cof_complete; + result["cof_change_in_progress"] = facts.cof_change_in_progress; + result["required_links_resolved"] = requiredPartsToLLSD(facts.required_links_resolved); + result["required_wearables_delivered"] = requiredPartsToLLSD(facts.required_wearables_delivered); + result["avatar_loaded"] = facts.avatar_loaded; + return result; +} +#endif } LLStatsListener::LLStatsListener() @@ -76,8 +474,64 @@ LLStatsListener::LLStatsListener() "Reply contains [\"stats\"] with nested group maps.", &LLStatsListener::getPerfData, llsd::map("reply", LLSD())); +#if defined(LL_RENDER_BENCHMARK) + add("getRendererDiagnosticState", + "Return paired scene and appearance facts for a diagnostic prime.", + &LLStatsListener::getRendererDiagnosticState, + llsd::map("reply", LLSD())); + add("normalizeRendererDisplay", + "Apply the renderer benchmark display scale after native window attachment.", + &LLStatsListener::normalizeRendererDisplay, + llsd::map("reply", LLSD())); +#endif } +#if defined(LL_RENDER_BENCHMARK) +void LLStatsListener::getRendererDiagnosticState(LLSD const& evt) +{ + LLEventAPI::Response response(LLSD(), evt); + LLSD scene_state = getRendererSceneState(); + LLSD appearance = getRendererAppearanceState(); + // Keep the diagnostic and the conservative scene gate on the same + // main-thread snapshot even at the fully-loaded transition. + scene_state["self_avatar_loaded"] = appearance["avatar_loaded"]; + response["scene_state"] = scene_state; + response["appearance"] = appearance; +} + +void LLStatsListener::normalizeRendererDisplay(LLSD const& evt) +{ + LLEventAPI::Response response(LLSD(), evt); + if (!gViewerWindow || !gViewerWindow->getWindow()) + { + return response.error("renderer window is not available"); + } + + // The factual Cocoa backing scale is only stable after the native window + // is attached. Reflow with the window's backing dimensions because the + // viewer's cached raw rectangle can still contain Cocoa logical points. + LLCoordWindow backing_size; + if (!gViewerWindow->getWindow()->getSize(&backing_size)) + { + return response.error("renderer backing size is not available"); + } + const LLCoordWindow requested_size( + gSavedSettings.getU32("WindowWidth"), + gSavedSettings.getU32("WindowHeight")); + if ((backing_size.mX != requested_size.mX || backing_size.mY != requested_size.mY) && + !gViewerWindow->getWindow()->setSize(requested_size)) + { + return response.error("renderer backing size could not be applied"); + } + if (!gViewerWindow->getWindow()->getSize(&backing_size)) + { + return response.error("renderer backing size is not available after resize"); + } + gViewerWindow->reshape(backing_size.mX, backing_size.mY); + response["accepted"] = true; +} +#endif + void LLStatsListener::getPerfData(LLSD const & evt) { LLEventAPI::Response response(LLSD(), evt); @@ -92,6 +546,21 @@ void LLStatsListener::getPerfData(LLSD const & evt) LLSD stats; stats["total_periods_duration"] = total_duration; stats["num_periods"] = (LLSD::Integer)num_periods; +#if defined(LL_RENDER_BENCHMARK) + stats["renderer_schema_version"] = 3; + stats["renderer_ready"] = LLStartUp::getStartupState() == STATE_STARTED; + stats["renderer_context"] = getRendererContext(); + stats["renderer_scene_state"] = getRendererSceneState(); + stats["renderer_frames"] = collectRendererFrames(recording, num_periods); + + LLSD instrumentation; + instrumentation["compile_time_enabled"] = true; + instrumentation["cpu_phase_timing"] = true; + instrumentation["resource_counters"] = true; + instrumentation["gpu_pass_timing"] = "external-diagnostic"; + instrumentation["gpu_query_readback_in_steady_loop"] = false; + stats["renderer_instrumentation"] = instrumentation; +#endif LLSD frametime; diff --git a/indra/newview/llstatslistener.h b/indra/newview/llstatslistener.h index 77777034c72..daa9bf92c36 100644 --- a/indra/newview/llstatslistener.h +++ b/indra/newview/llstatslistener.h @@ -36,6 +36,8 @@ class LLStatsListener : public LLEventAPI private: void getPerfData(LLSD const & evt); + void getRendererDiagnosticState(LLSD const& evt); + void normalizeRendererDisplay(LLSD const& evt); }; #endif // LL_LLSTATSLISTENER_H diff --git a/indra/newview/lltextureuploadparity.cpp b/indra/newview/lltextureuploadparity.cpp new file mode 100644 index 00000000000..c771b808eeb --- /dev/null +++ b/indra/newview/lltextureuploadparity.cpp @@ -0,0 +1,1562 @@ +/** + * @file lltextureuploadparity.cpp + * @brief Account-free parity harness for one streaming texture upload. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "llviewerprecompiledheaders.h" + +#include "lltextureuploadparity.h" + +#include "llgl.h" +#include "llglslshader.h" +#include "llglstates.h" +#include "llimage.h" +#include "llimagegl.h" +#include "llrendergltextureupload.h" +#include "llrendertarget.h" +#include "llshadermgr.h" +#include "lltextureuploaddiagnostic.h" +#include "llvertexbuffer.h" +#include "llviewercontrol.h" +#include "llviewershadermgr.h" +#include "pipeline.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +namespace LLTextureUploadParity +{ +namespace +{ + +using namespace LLRenderContract; + +constexpr const char* TEXTURE_UPLOAD_PROGRAM = "contract.sample-texture"; + +struct TextureResources +{ + LLPointer mScreenTriangle; + LLPointer mOldImage; + LLPointer mReplacementImage; + LLRenderTarget mOutput; + GLuint mSampler = 0; + GLuint mSavedReplacementName = 0; + LLRenderGLTextureUpload::LifecycleLedger mLifecycle; + + TextureResources() = default; + TextureResources(const TextureResources&) = delete; + TextureResources& operator=(const TextureResources&) = delete; + + ~TextureResources() + { + if (mSampler != 0) + { + glDeleteSamplers(1, &mSampler); + } + } +}; + +struct TextureSnapshot +{ + std::array, TEXTURE_UPLOAD_MIP_LEVELS> mOldMips; + std::array, TEXTURE_UPLOAD_MIP_LEVELS> mReplacementMips; + std::vector mOutput; + LLRenderGLTextureUpload::LifecycleLedger mLifecycle; + + friend bool operator==(const TextureSnapshot&, const TextureSnapshot&) = default; +}; + +struct AmbientVertexAttribute +{ + GLint mEnabled = 0; + GLint mSize = 0; + GLint mStride = 0; + GLint mType = 0; + GLint mNormalized = 0; + GLint mInteger = 0; + GLint mDivisor = 0; + GLint mBuffer = 0; + void* mPointer = nullptr; +#ifdef GL_VERTEX_ATTRIB_ARRAY_LONG + GLint mLong = 0; +#endif + + friend bool operator==(const AmbientVertexAttribute&, const AmbientVertexAttribute&) = default; +}; + +struct AmbientGLState +{ + GLint mActiveTexture = 0; + U32 mCachedActiveTexture = 0; + GLint mTexture2D = 0; + GLint mSampler = 0; + U32 mCachedTexture = 0; + LLTexUnit::eTextureType mCachedTextureType = LLTexUnit::TT_NONE; + bool mCachedTextureHasMips = false; + GLint mRawCachedTargetTexture = 0; + + GLint mPackBuffer = 0; + GLint mUnpackBuffer = 0; + std::array mPixelStore{}; +#ifdef GL_PACK_INVERT_MESA + GLint mPackInvert = GL_FALSE; +#endif +#ifdef GL_UNPACK_CLIENT_STORAGE_APPLE + GLint mClientStorage = GL_FALSE; +#endif + + GLint mArrayBuffer = 0; + GLint mElementBuffer = 0; + GLint mVertexArray = 0; + U32 mCachedArrayBuffer = 0; + U32 mCachedElementBuffer = 0; + U32 mCachedAttributeMask = 0; + std::vector mAttributes; + + GLint mProgram = 0; + GLuint mCachedProgram = 0; + LLGLSLShader* mCachedShader = nullptr; + GLint mDiffuseUniform = 0; + bool mCopyUniformsDirty = false; + + GLint mDrawFramebuffer = 0; + GLint mReadFramebuffer = 0; + GLint mReadBuffer = 0; + std::vector mDrawBuffers; + LLRenderTarget* mBoundTarget = nullptr; + U32 mCachedFramebuffer = 0; + U32 mCachedWidth = 0; + U32 mCachedHeight = 0; + + std::array mViewport{}; + std::array mScissor{}; + std::array mColorMask{}; + bool mHasClipControl = false; + GLint mClipOrigin = GL_LOWER_LEFT; + GLint mClipDepthMode = GL_NEGATIVE_ONE_TO_ONE; + std::array mPolygonMode{}; + GLint mProfileMask = 0; + std::vector> mCapabilities; + + friend bool operator==(const AmbientGLState&, const AmbientGLState&) = default; +}; + +GLenum textureBindingQuery(LLTexUnit::eTextureType type) +{ + switch (type) + { + case LLTexUnit::TT_TEXTURE: return GL_TEXTURE_BINDING_2D; + case LLTexUnit::TT_RECT_TEXTURE: return GL_TEXTURE_BINDING_RECTANGLE; + case LLTexUnit::TT_CUBE_MAP: return GL_TEXTURE_BINDING_CUBE_MAP; + case LLTexUnit::TT_CUBE_MAP_ARRAY: return GL_TEXTURE_BINDING_CUBE_MAP_ARRAY; + case LLTexUnit::TT_MULTISAMPLE_TEXTURE: return GL_TEXTURE_BINDING_2D_MULTISAMPLE; + case LLTexUnit::TT_TEXTURE_3D: return GL_TEXTURE_BINDING_3D; + case LLTexUnit::TT_NONE: return GL_NONE; + } + return GL_NONE; +} + +bool captureAmbientGLState(AmbientGLState& state) +{ + glGetIntegerv(GL_ACTIVE_TEXTURE, &state.mActiveTexture); + state.mCachedActiveTexture = gGL.getCurrentTexUnitIndex(); + glActiveTexture(GL_TEXTURE0); + glGetIntegerv(GL_TEXTURE_BINDING_2D, &state.mTexture2D); + glGetIntegerv(GL_SAMPLER_BINDING, &state.mSampler); + LLTexUnit* unit = gGL.getTexUnit(0); + state.mCachedTexture = unit->getCurrTexture(); + state.mCachedTextureType = unit->getCurrType(); + state.mCachedTextureHasMips = unit->getHasMipMaps(); + const GLenum cached_binding_query = textureBindingQuery(state.mCachedTextureType); + if (cached_binding_query != GL_NONE) + { + glGetIntegerv(cached_binding_query, &state.mRawCachedTargetTexture); + } + glActiveTexture(static_cast(state.mActiveTexture)); + + glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &state.mPackBuffer); + glGetIntegerv(GL_PIXEL_UNPACK_BUFFER_BINDING, &state.mUnpackBuffer); + constexpr std::array pixel_store_names{ + GL_PACK_ALIGNMENT, GL_PACK_ROW_LENGTH, GL_PACK_SKIP_ROWS, GL_PACK_SKIP_PIXELS, + GL_PACK_IMAGE_HEIGHT, GL_PACK_SKIP_IMAGES, GL_PACK_SWAP_BYTES, GL_PACK_LSB_FIRST, + GL_UNPACK_ALIGNMENT, GL_UNPACK_ROW_LENGTH, GL_UNPACK_SKIP_ROWS, GL_UNPACK_SKIP_PIXELS, + GL_UNPACK_IMAGE_HEIGHT, GL_UNPACK_SKIP_IMAGES, GL_UNPACK_SWAP_BYTES, GL_UNPACK_LSB_FIRST + }; + for (std::size_t index = 0; index < pixel_store_names.size(); ++index) + { + glGetIntegerv(pixel_store_names[index], &state.mPixelStore[index]); + } +#ifdef GL_PACK_INVERT_MESA + if (gGLManager.mGLExtensions.contains("GL_MESA_pack_invert")) + { + glGetIntegerv(GL_PACK_INVERT_MESA, &state.mPackInvert); + } +#endif +#ifdef GL_UNPACK_CLIENT_STORAGE_APPLE + if (gGLManager.mGLExtensions.contains("GL_APPLE_client_storage")) + { + glGetIntegerv(GL_UNPACK_CLIENT_STORAGE_APPLE, &state.mClientStorage); + } +#endif + + glGetIntegerv(GL_ARRAY_BUFFER_BINDING, &state.mArrayBuffer); + glGetIntegerv(GL_ELEMENT_ARRAY_BUFFER_BINDING, &state.mElementBuffer); + glGetIntegerv(GL_VERTEX_ARRAY_BINDING, &state.mVertexArray); + state.mCachedArrayBuffer = LLVertexBuffer::sGLRenderBuffer; + state.mCachedElementBuffer = LLVertexBuffer::sGLRenderIndices; + state.mCachedAttributeMask = LLVertexBuffer::sLastMask; + GLint attribute_count = 0; + glGetIntegerv(GL_MAX_VERTEX_ATTRIBS, &attribute_count); + state.mAttributes.resize(static_cast(std::max(0, attribute_count))); + for (GLuint index = 0; index < state.mAttributes.size(); ++index) + { + AmbientVertexAttribute& attribute = state.mAttributes[index]; + glGetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_ENABLED, &attribute.mEnabled); + glGetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_SIZE, &attribute.mSize); + glGetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_STRIDE, &attribute.mStride); + glGetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_TYPE, &attribute.mType); + glGetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_NORMALIZED, &attribute.mNormalized); + glGetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_INTEGER, &attribute.mInteger); + glGetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_DIVISOR, &attribute.mDivisor); + glGetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_BUFFER_BINDING, &attribute.mBuffer); + glGetVertexAttribPointerv(index, GL_VERTEX_ATTRIB_ARRAY_POINTER, &attribute.mPointer); +#ifdef GL_VERTEX_ATTRIB_ARRAY_LONG + if (gGLManager.mGLVersion >= 4.29f) + { + glGetVertexAttribiv(index, GL_VERTEX_ATTRIB_ARRAY_LONG, &attribute.mLong); + } +#endif + } + + glGetIntegerv(GL_CURRENT_PROGRAM, &state.mProgram); + state.mCachedProgram = LLGLSLShader::sCurBoundShader; + state.mCachedShader = LLGLSLShader::sCurBoundShaderPtr; + state.mCopyUniformsDirty = gCopyProgram.mUniformsDirty; + const GLint diffuse_location = glGetUniformLocation(gCopyProgram.mProgramObject, "diffuseMap"); + if (diffuse_location >= 0) + { + glGetUniformiv(gCopyProgram.mProgramObject, diffuse_location, &state.mDiffuseUniform); + } + + glGetIntegerv(GL_DRAW_FRAMEBUFFER_BINDING, &state.mDrawFramebuffer); + glGetIntegerv(GL_READ_FRAMEBUFFER_BINDING, &state.mReadFramebuffer); + glGetIntegerv(GL_READ_BUFFER, &state.mReadBuffer); + GLint draw_buffer_count = 0; + glGetIntegerv(GL_MAX_DRAW_BUFFERS, &draw_buffer_count); + state.mDrawBuffers.resize(static_cast(std::max(1, draw_buffer_count))); + for (GLint index = 0; index < draw_buffer_count; ++index) + { + glGetIntegerv(static_cast(GL_DRAW_BUFFER0 + index), &state.mDrawBuffers[static_cast(index)]); + } + state.mBoundTarget = LLRenderTarget::getCurrentBoundTarget(); + state.mCachedFramebuffer = LLRenderTarget::sCurFBO; + state.mCachedWidth = LLRenderTarget::sCurResX; + state.mCachedHeight = LLRenderTarget::sCurResY; + + glGetIntegerv(GL_VIEWPORT, state.mViewport.data()); + glGetIntegerv(GL_SCISSOR_BOX, state.mScissor.data()); + glGetBooleanv(GL_COLOR_WRITEMASK, state.mColorMask.data()); +#if !LL_DARWIN + state.mHasClipControl = gGLManager.mGLVersion >= 4.49f && glClipControl; + if (state.mHasClipControl) + { + glGetIntegerv(GL_CLIP_ORIGIN, &state.mClipOrigin); + glGetIntegerv(GL_CLIP_DEPTH_MODE, &state.mClipDepthMode); + } +#endif + glGetIntegerv(GL_CONTEXT_PROFILE_MASK, &state.mProfileMask); + glGetIntegerv(GL_POLYGON_MODE, state.mPolygonMode.data()); + if ((state.mProfileMask & GL_CONTEXT_CORE_PROFILE_BIT) != 0) + { + state.mPolygonMode[1] = state.mPolygonMode[0]; + } + constexpr std::array capabilities{ GL_BLEND, GL_CULL_FACE, GL_DEPTH_TEST, GL_STENCIL_TEST, + GL_SCISSOR_TEST, GL_DITHER, GL_FRAMEBUFFER_SRGB, + GL_RASTERIZER_DISCARD, GL_MULTISAMPLE, + GL_SAMPLE_ALPHA_TO_COVERAGE, GL_SAMPLE_ALPHA_TO_ONE, + GL_SAMPLE_COVERAGE, GL_SAMPLE_MASK, GL_SAMPLE_SHADING, + GL_COLOR_LOGIC_OP }; + for (GLenum capability : capabilities) + { + state.mCapabilities.emplace_back(capability, glIsEnabled(capability) == GL_TRUE); + } + GLint clip_distance_count = 0; + glGetIntegerv(GL_MAX_CLIP_DISTANCES, &clip_distance_count); + for (GLint index = 0; index < clip_distance_count; ++index) + { + const GLenum capability = static_cast(GL_CLIP_DISTANCE0 + index); + state.mCapabilities.emplace_back(capability, glIsEnabled(capability) == GL_TRUE); + } + return glGetError() == GL_NO_ERROR; +} + +struct AmbientPoisonBuffers +{ + std::array mNames{}; + std::array mCubeNames{}; + GLuint mVertexArray = 0; + GLint mCopyDiffuseLocation = -1; + GLint mCopyDiffuseValue = 0; + bool mCopyUniformsDirty = false; + + bool initialize() + { + glGenBuffers(static_cast(mNames.size()), mNames.data()); + glGenTextures(static_cast(mCubeNames.size()), mCubeNames.data()); + glGenVertexArrays(1, &mVertexArray); + if (mNames[0] == 0 || mNames[1] == 0 || mCubeNames[0] == 0 || mCubeNames[1] == 0 || mVertexArray == 0) + { + return false; + } + glBindBuffer(GL_PIXEL_PACK_BUFFER, mNames[0]); + glBufferData(GL_PIXEL_PACK_BUFFER, 4096, nullptr, GL_STREAM_READ); + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, mNames[1]); + glBufferData(GL_PIXEL_UNPACK_BUFFER, 4096, nullptr, GL_STREAM_DRAW); + return glGetError() == GL_NO_ERROR; + } + + void cleanup() + { + glBindVertexArray(0); + if (mVertexArray != 0) + { + glDeleteVertexArrays(1, &mVertexArray); + mVertexArray = 0; + } + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + if (mNames[0] != 0 || mNames[1] != 0) + { + glDeleteBuffers(static_cast(mNames.size()), mNames.data()); + mNames = {}; + } + if (mCubeNames[0] != 0 || mCubeNames[1] != 0) + { + glDeleteTextures(static_cast(mCubeNames.size()), mCubeNames.data()); + mCubeNames = {}; + } + LLVertexBuffer::sGLRenderBuffer = 0; + LLVertexBuffer::sGLRenderIndices = 0; + LLVertexBuffer::sLastMask = 0; + } + + ~AmbientPoisonBuffers() { cleanup(); } +}; + +bool poisonAmbientGLState(TextureResources& ambient_resources, AmbientPoisonBuffers& buffers) +{ + clear_glerror(); + ambient_resources.mOutput.bindTarget(); + buffers.mCopyUniformsDirty = gCopyProgram.mUniformsDirty; + gCopyProgram.mUniformsDirty = false; + gCopyProgram.bind(); + buffers.mCopyDiffuseLocation = glGetUniformLocation(gCopyProgram.mProgramObject, "diffuseMap"); + if (buffers.mCopyDiffuseLocation < 0) + { + return false; + } + glGetUniformiv(gCopyProgram.mProgramObject, buffers.mCopyDiffuseLocation, &buffers.mCopyDiffuseValue); + glUniform1i(buffers.mCopyDiffuseLocation, 3); + ambient_resources.mScreenTriangle->setBuffer(); + if (!gGL.getTexUnit(0)->bindManual(LLTexUnit::TT_TEXTURE, ambient_resources.mOldImage->getTexName(), true)) + { + return false; + } + gGL.getTexUnit(0)->setHasMipMaps(false); + glBindSampler(0, ambient_resources.mSampler); + if (!buffers.initialize()) + { + return false; + } + if (!gGL.getTexUnit(0)->bindManual(LLTexUnit::TT_CUBE_MAP, buffers.mCubeNames[0], false)) + { + return false; + } + glBindTexture(GL_TEXTURE_CUBE_MAP, buffers.mCubeNames[1]); + glBindTexture(GL_TEXTURE_2D, ambient_resources.mOldImage->getTexName()); + if (!gDebugProgram.isComplete() || gDebugProgram.mProgramObject == gCopyProgram.mProgramObject) + { + return false; + } + gDebugProgram.bind(); + gCopyProgram.mUniformsDirty = true; + + glPixelStorei(GL_PACK_ALIGNMENT, 8); + glPixelStorei(GL_PACK_ROW_LENGTH, 13); + glPixelStorei(GL_PACK_SKIP_ROWS, 1); + glPixelStorei(GL_PACK_SKIP_PIXELS, 2); + glPixelStorei(GL_PACK_IMAGE_HEIGHT, 7); + glPixelStorei(GL_PACK_SKIP_IMAGES, 1); + glPixelStorei(GL_PACK_SWAP_BYTES, GL_TRUE); + glPixelStorei(GL_PACK_LSB_FIRST, GL_TRUE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 8); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 13); + glPixelStorei(GL_UNPACK_SKIP_ROWS, 1); + glPixelStorei(GL_UNPACK_SKIP_PIXELS, 2); + glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, 7); + glPixelStorei(GL_UNPACK_SKIP_IMAGES, 1); + glPixelStorei(GL_UNPACK_SWAP_BYTES, GL_TRUE); + glPixelStorei(GL_UNPACK_LSB_FIRST, GL_TRUE); +#ifdef GL_PACK_INVERT_MESA + if (gGLManager.mGLExtensions.contains("GL_MESA_pack_invert")) + { + glPixelStorei(GL_PACK_INVERT_MESA, GL_TRUE); + } +#endif +#ifdef GL_UNPACK_CLIENT_STORAGE_APPLE + if (gGLManager.mGLExtensions.contains("GL_APPLE_client_storage")) + { + glPixelStorei(GL_UNPACK_CLIENT_STORAGE_APPLE, GL_TRUE); + } +#endif + + glViewport(1, 0, 3, 2); + glScissor(1, 0, 2, 1); + gGL.setColorMask(false, true, false, true); +#if !LL_DARWIN + if (gGLManager.mGLVersion >= 4.49f && glClipControl) + { + glClipControl(GL_UPPER_LEFT, GL_ZERO_TO_ONE); + } +#endif + GLint profile_mask = 0; + glGetIntegerv(GL_CONTEXT_PROFILE_MASK, &profile_mask); + if ((profile_mask & GL_CONTEXT_COMPATIBILITY_PROFILE_BIT) == 0) + { + glPolygonMode(GL_FRONT_AND_BACK, GL_LINE); + } + else + { + glPolygonMode(GL_FRONT, GL_LINE); + glPolygonMode(GL_BACK, GL_POINT); + } + constexpr std::array enabled{ GL_BLEND, + GL_CULL_FACE, + GL_DEPTH_TEST, + GL_STENCIL_TEST, + GL_SCISSOR_TEST, + GL_DITHER, + GL_FRAMEBUFFER_SRGB, + GL_RASTERIZER_DISCARD, + GL_MULTISAMPLE, + GL_SAMPLE_ALPHA_TO_COVERAGE, + GL_SAMPLE_ALPHA_TO_ONE, + GL_SAMPLE_COVERAGE, + GL_SAMPLE_MASK, + GL_SAMPLE_SHADING, + GL_COLOR_LOGIC_OP }; + for (GLenum capability : enabled) + { + glEnable(capability); + } + GLint clip_distance_count = 0; + glGetIntegerv(GL_MAX_CLIP_DISTANCES, &clip_distance_count); + for (GLint index = 0; index < clip_distance_count; ++index) + { + glEnable(static_cast(GL_CLIP_DISTANCE0 + index)); + } + glBindVertexArray(buffers.mVertexArray); + glBindBuffer(GL_ARRAY_BUFFER, buffers.mNames[0]); + glBindBuffer(GL_ELEMENT_ARRAY_BUFFER, buffers.mNames[1]); + LLVertexBuffer::sGLRenderBuffer = buffers.mNames[0]; + LLVertexBuffer::sGLRenderIndices = buffers.mNames[1]; + LLVertexBuffer::sLastMask = 0; + gGL.getTexUnit(1)->activate(); + return glGetError() == GL_NO_ERROR; +} + +void cleanupAmbientGLState(TextureResources& ambient_resources, AmbientPoisonBuffers& buffers) +{ + gGL.getTexUnit(0)->activate(); + if (ambient_resources.mOutput.isBoundInStack()) + { + ambient_resources.mOutput.flush(); + } + gCopyProgram.mUniformsDirty = false; + gCopyProgram.bind(); + if (buffers.mCopyDiffuseLocation >= 0) + { + glUniform1i(buffers.mCopyDiffuseLocation, buffers.mCopyDiffuseValue); + } + gCopyProgram.mUniformsDirty = buffers.mCopyUniformsDirty; + gCopyProgram.unbind(); + glBindSampler(0, 0); + buffers.cleanup(); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glPixelStorei(GL_PACK_ROW_LENGTH, 0); + glPixelStorei(GL_PACK_SKIP_ROWS, 0); + glPixelStorei(GL_PACK_SKIP_PIXELS, 0); + glPixelStorei(GL_PACK_IMAGE_HEIGHT, 0); + glPixelStorei(GL_PACK_SKIP_IMAGES, 0); + glPixelStorei(GL_PACK_SWAP_BYTES, GL_FALSE); + glPixelStorei(GL_PACK_LSB_FIRST, GL_FALSE); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + glPixelStorei(GL_UNPACK_SKIP_ROWS, 0); + glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0); + glPixelStorei(GL_UNPACK_IMAGE_HEIGHT, 0); + glPixelStorei(GL_UNPACK_SKIP_IMAGES, 0); + glPixelStorei(GL_UNPACK_SWAP_BYTES, GL_FALSE); + glPixelStorei(GL_UNPACK_LSB_FIRST, GL_FALSE); +#ifdef GL_PACK_INVERT_MESA + if (gGLManager.mGLExtensions.contains("GL_MESA_pack_invert")) glPixelStorei(GL_PACK_INVERT_MESA, GL_FALSE); +#endif +#ifdef GL_UNPACK_CLIENT_STORAGE_APPLE + if (gGLManager.mGLExtensions.contains("GL_APPLE_client_storage")) + { + glPixelStorei(GL_UNPACK_CLIENT_STORAGE_APPLE, GL_FALSE); + } +#endif + constexpr std::array enabled{ GL_BLEND, + GL_CULL_FACE, + GL_DEPTH_TEST, + GL_STENCIL_TEST, + GL_SCISSOR_TEST, + GL_DITHER, + GL_FRAMEBUFFER_SRGB, + GL_RASTERIZER_DISCARD, + GL_MULTISAMPLE, + GL_SAMPLE_ALPHA_TO_COVERAGE, + GL_SAMPLE_ALPHA_TO_ONE, + GL_SAMPLE_COVERAGE, + GL_SAMPLE_MASK, + GL_SAMPLE_SHADING, + GL_COLOR_LOGIC_OP }; + for (GLenum capability : enabled) + { + glDisable(capability); + } + glEnable(GL_DITHER); + GLint clip_distance_count = 0; + glGetIntegerv(GL_MAX_CLIP_DISTANCES, &clip_distance_count); + for (GLint index = 0; index < clip_distance_count; ++index) + { + glDisable(static_cast(GL_CLIP_DISTANCE0 + index)); + } + glPolygonMode(GL_FRONT_AND_BACK, GL_FILL); +#if !LL_DARWIN + if (gGLManager.mGLVersion >= 4.49f && glClipControl) + { + glClipControl(GL_LOWER_LEFT, GL_NEGATIVE_ONE_TO_ONE); + } +#endif + gGL.setColorMask(true, true, true, true); + gGL.getTexUnit(0)->disable(); + clear_glerror(); +} + +bool emitFailure(const std::string& reason) +{ + const std::string result = "TEXTURE_UPLOAD_CONTRACT_PARITY result=fail reason=" + reason; + std::cout << result << std::endl; + LL_INFOS("RenderContractParity") << result << LL_ENDL; + return false; +} + +std::vector tightBottomLeftSource(const StreamingUploadInputs& inputs) +{ + constexpr std::size_t row_bytes = TEXTURE_UPLOAD_RESIDENT_WIDTH * TEXTURE_UPLOAD_CHANNELS; + std::vector result(row_bytes * TEXTURE_UPLOAD_RESIDENT_HEIGHT); + for (std::uint32_t destination_row = 0; destination_row < TEXTURE_UPLOAD_RESIDENT_HEIGHT; ++destination_row) + { + const std::uint32_t source_row = TEXTURE_UPLOAD_RESIDENT_HEIGHT - 1 - destination_row; + std::copy_n(inputs.mPixels.data() + source_row * inputs.mRowPitch, row_bytes, + result.data() + destination_row * row_bytes); + } + return result; +} + +LLPointer rawImage(const std::uint8_t* bytes, std::uint32_t width, std::uint32_t height) +{ + return new LLImageRaw(bytes, static_cast(width), static_cast(height), static_cast(TEXTURE_UPLOAD_CHANNELS)); +} + +bool setCanonicalTextureParameters(GLuint name) +{ + if (!gGL.getTexUnit(0)->bindManual(LLTexUnit::TT_TEXTURE, name, true)) + { + return false; + } + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 0); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAX_LEVEL, TEXTURE_UPLOAD_MIP_LEVELS - 1); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_COMPARE_MODE, GL_NONE); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MIN_LOD, -1000.f); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_LOD, 1000.f); + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_LOD_BIAS, 0.f); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_R, GL_RED); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_G, GL_GREEN); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_B, GL_BLUE); + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_SWIZZLE_A, GL_ALPHA); + if (gGLManager.mHasAnisotropic) + { + glTexParameterf(GL_TEXTURE_2D, GL_TEXTURE_MAX_ANISOTROPY, 1.f); + } + return glGetError() == GL_NO_ERROR; +} + +bool seedImage(LLPointer& image, S32 discard, + const std::array& mip_bytes) +{ + image = new LLImageGL(true, false); + image->setNeedsAlphaAndPickMask(false); + image->setExplicitFormat(GL_RGBA8, GL_RGBA, GL_UNSIGNED_BYTE); + + const LLPointer base = rawImage(mip_bytes.data(), TEXTURE_UPLOAD_RESIDENT_WIDTH, + TEXTURE_UPLOAD_RESIDENT_HEIGHT); + clear_glerror(); + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + glPixelStorei(GL_UNPACK_SKIP_ROWS, 0); + glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0); + if (!image->createGLTexture(discard, base) || image->getTexName() == 0) + { + return false; + } + + if (!gGL.getTexUnit(0)->bindManual(LLTexUnit::TT_TEXTURE, image->getTexName(), true)) + { + return false; + } + for (std::uint32_t mip = 0; mip < TEXTURE_UPLOAD_MIP_LEVELS; ++mip) + { + const GLsizei width = static_cast(TEXTURE_UPLOAD_RESIDENT_WIDTH >> mip); + const GLsizei height = static_cast(TEXTURE_UPLOAD_RESIDENT_HEIGHT >> mip); + LLImageGL::setManualImage(GL_TEXTURE_2D, static_cast(mip), GL_RGBA8, width, height, GL_RGBA, + GL_UNSIGNED_BYTE, mip_bytes.data() + TEXTURE_UPLOAD_MIP_BYTE_OFFSETS[mip], false); + } + return setCanonicalTextureParameters(image->getTexName()); +} + +bool initializeScreenTriangle(TextureResources& resources, const TextureUploadFixture& fixture) +{ + resources.mScreenTriangle = new LLVertexBuffer(LLVertexBuffer::MAP_VERTEX); + if (!resources.mScreenTriangle->allocateBuffer(3, 0) || resources.mScreenTriangle->getSize() != 48 || + resources.mScreenTriangle->getOffset(LLVertexBuffer::TYPE_VERTEX) != 0) + { + return false; + } + U8* destination = resources.mScreenTriangle->mapVertexBuffer(LLVertexBuffer::TYPE_VERTEX, 0, 3); + if (!destination) + { + return false; + } + std::memcpy(destination, fixture.mScreenTriangle.data(), sizeof(fixture.mScreenTriangle)); + resources.mScreenTriangle->unmapBuffer(); + return true; +} + +bool initializeSampler(TextureResources& resources) +{ + glGenSamplers(1, &resources.mSampler); + if (resources.mSampler == 0) + { + return false; + } + glSamplerParameteri(resources.mSampler, GL_TEXTURE_MIN_FILTER, GL_LINEAR_MIPMAP_LINEAR); + glSamplerParameteri(resources.mSampler, GL_TEXTURE_MAG_FILTER, GL_LINEAR); + glSamplerParameteri(resources.mSampler, GL_TEXTURE_WRAP_S, GL_CLAMP_TO_EDGE); + glSamplerParameteri(resources.mSampler, GL_TEXTURE_WRAP_T, GL_CLAMP_TO_EDGE); + glSamplerParameteri(resources.mSampler, GL_TEXTURE_COMPARE_MODE, GL_NONE); + glSamplerParameterf(resources.mSampler, GL_TEXTURE_MIN_LOD, -1000.f); + glSamplerParameterf(resources.mSampler, GL_TEXTURE_MAX_LOD, 1000.f); + glSamplerParameterf(resources.mSampler, GL_TEXTURE_LOD_BIAS, 0.f); + if (gGLManager.mHasAnisotropic) + { + glSamplerParameterf(resources.mSampler, GL_TEXTURE_MAX_ANISOTROPY, 1.f); + } + return glGetError() == GL_NO_ERROR; +} + +bool seedOutput(TextureResources& resources, const TextureUploadFixture& fixture) +{ + if (!resources.mOutput.allocate(TEXTURE_UPLOAD_OUTPUT_WIDTH, TEXTURE_UPLOAD_OUTPUT_HEIGHT, GL_RGBA, false) || + !resources.mOutput.isComplete() || resources.mOutput.getNumTextures() != 1) + { + return false; + } + if (!gGL.getTexUnit(0)->bindManual(resources.mOutput.getUsage(), resources.mOutput.getTexture(0), true)) + { + return false; + } + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + glPixelStorei(GL_UNPACK_SKIP_ROWS, 0); + glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, TEXTURE_UPLOAD_OUTPUT_WIDTH, TEXTURE_UPLOAD_OUTPUT_HEIGHT, + GL_RGBA, GL_UNSIGNED_BYTE, fixture.mOutputSentinelRGBA8.data()); + return glGetError() == GL_NO_ERROR; +} + +bool initializeResources(TextureResources& resources, const TextureUploadFixture& fixture) +{ + clear_glerror(); + if (!initializeScreenTriangle(resources, fixture) || + !seedImage(resources.mOldImage, TEXTURE_UPLOAD_RESIDENT_DISCARD, fixture.mOldMipRGBA8) || + !seedImage(resources.mReplacementImage, TEXTURE_UPLOAD_RESIDENT_DISCARD, + fixture.mReplacementSentinelMipRGBA8) || + !seedOutput(resources, fixture) || !initializeSampler(resources)) + { + return false; + } + resources.mLifecycle.mCurrentImage = StreamingUploadHandles{}.mOldImage; + resources.mLifecycle.mLastRevision = fixture.mPriorRevision; + resources.mLifecycle.mCompletionPending = false; + return glGetError() == GL_NO_ERROR; +} + +bool readTextureMip(GLuint texture, std::uint32_t mip, std::vector& bytes) +{ + const std::uint32_t width = TEXTURE_UPLOAD_RESIDENT_WIDTH >> mip; + const std::uint32_t height = TEXTURE_UPLOAD_RESIDENT_HEIGHT >> mip; + bytes.assign(static_cast(width) * height * TEXTURE_UPLOAD_CHANNELS, 0); + + GLint active_texture = GL_TEXTURE0; + GLint binding = 0; + GLint pack_buffer = 0; + GLint alignment = 4; + GLint row_length = 0; + GLint skip_rows = 0; + GLint skip_pixels = 0; + glGetIntegerv(GL_ACTIVE_TEXTURE, &active_texture); + glActiveTexture(GL_TEXTURE0); + glGetIntegerv(GL_TEXTURE_BINDING_2D, &binding); + glGetIntegerv(GL_PIXEL_PACK_BUFFER_BINDING, &pack_buffer); + glGetIntegerv(GL_PACK_ALIGNMENT, &alignment); + glGetIntegerv(GL_PACK_ROW_LENGTH, &row_length); + glGetIntegerv(GL_PACK_SKIP_ROWS, &skip_rows); + glGetIntegerv(GL_PACK_SKIP_PIXELS, &skip_pixels); + + glBindBuffer(GL_PIXEL_PACK_BUFFER, 0); + glPixelStorei(GL_PACK_ALIGNMENT, 1); + glPixelStorei(GL_PACK_ROW_LENGTH, 0); + glPixelStorei(GL_PACK_SKIP_ROWS, 0); + glPixelStorei(GL_PACK_SKIP_PIXELS, 0); + glBindTexture(GL_TEXTURE_2D, texture); + glGetTexImage(GL_TEXTURE_2D, static_cast(mip), GL_RGBA, GL_UNSIGNED_BYTE, bytes.data()); + const bool success = glGetError() == GL_NO_ERROR; + + glBindTexture(GL_TEXTURE_2D, static_cast(binding)); + glPixelStorei(GL_PACK_ALIGNMENT, alignment); + glPixelStorei(GL_PACK_ROW_LENGTH, row_length); + glPixelStorei(GL_PACK_SKIP_ROWS, skip_rows); + glPixelStorei(GL_PACK_SKIP_PIXELS, skip_pixels); + glBindBuffer(GL_PIXEL_PACK_BUFFER, static_cast(pack_buffer)); + glActiveTexture(static_cast(active_texture)); + return success && glGetError() == GL_NO_ERROR; +} + +bool readImage(const LLImageGL& image, + std::array, TEXTURE_UPLOAD_MIP_LEVELS>& mips) +{ + if (image.getTexName() == 0) + { + return false; + } + for (std::uint32_t mip = 0; mip < TEXTURE_UPLOAD_MIP_LEVELS; ++mip) + { + if (!readTextureMip(image.getTexName(), mip, mips[mip])) + { + return false; + } + } + return true; +} + +bool readOutput(const LLRenderTarget& output, std::vector& bytes) +{ + if (!readTextureMip(output.getTexture(0), 0, bytes)) + { + return false; + } + bytes.resize(TEXTURE_UPLOAD_OUTPUT_BYTE_COUNT); + return true; +} + +bool snapshot(const TextureResources& resources, TextureSnapshot& result) +{ + return readImage(*resources.mOldImage, result.mOldMips) && + readImage(*resources.mReplacementImage, result.mReplacementMips) && + readOutput(resources.mOutput, result.mOutput) && + ((result.mLifecycle = resources.mLifecycle), true); +} + +bool sampleReplacement(TextureResources& resources) +{ + clear_glerror(); + resources.mOutput.bindTarget(); + { + LLGLDepthTest depth(GL_FALSE, GL_FALSE); + LLGLDisable blend(GL_BLEND); + LLGLDisable scissor(GL_SCISSOR_TEST); + gGL.setColorMask(true, true); + gCopyProgram.bind(); + const S32 channel = gCopyProgram.enableTexture(LLShaderMgr::DIFFUSE_MAP); + if (channel < 0 || !gGL.getTexUnit(channel)->bind(resources.mReplacementImage, true, true)) + { + gCopyProgram.unbind(); + resources.mOutput.flush(); + return false; + } + glBindSampler(static_cast(channel), resources.mSampler); + resources.mScreenTriangle->setBuffer(); + resources.mScreenTriangle->drawArrays(LLRender::TRIANGLES, 0, 3); + glBindSampler(static_cast(channel), 0); + gCopyProgram.disableTexture(LLShaderMgr::DIFFUSE_MAP); + gCopyProgram.unbind(); + } + resources.mOutput.flush(); + return glGetError() == GL_NO_ERROR; +} + +bool captureArtifactPixels(TextureResources& resources, TextureUploadArtifact& result) +{ + result = makeTextureUploadArtifact(); + return readImage(*resources.mReplacementImage, result.mMipRGBA8) && + readOutput(resources.mOutput, result.mSampledRGBA8); +} + +bool hasMultipleChangedTexels(const std::vector& before, const std::vector& after) +{ + if (before.size() != after.size() || after.size() < TEXTURE_UPLOAD_CHANNELS * 2 || + after.size() % TEXTURE_UPLOAD_CHANNELS != 0) + { + return false; + } + std::optional first_changed; + for (std::size_t offset = 0; offset < after.size(); offset += TEXTURE_UPLOAD_CHANNELS) + { + const auto before_texel = std::next(before.begin(), static_cast(offset)); + const auto after_texel = std::next(after.begin(), static_cast(offset)); + if (!std::equal(after_texel, std::next(after_texel, TEXTURE_UPLOAD_CHANNELS), before_texel)) + { + if (!first_changed) + { + first_changed = offset; + } + else + { + const auto first_texel = std::next(after.begin(), static_cast(*first_changed)); + if (!std::equal(after_texel, std::next(after_texel, TEXTURE_UPLOAD_CHANNELS), first_texel)) + { + return true; + } + } + } + } + return false; +} + +void populateLifecycleEvidence(TextureUploadArtifact& artifact, const StreamingUploadInputs& inputs, + const LLRenderGLTextureUpload::LifecycleLedger& ledger) +{ + artifact.mPriorRevision = ledger.mLastRevision; + artifact.mRevision = inputs.mRevision; + artifact.mCompletionCount = ledger.mCompletionCount + 1; + artifact.mCompletedDestination = inputs.mHandles.mReplacementImage; + artifact.mCompletedRevision = inputs.mRevision; + artifact.mCompletedFrame = inputs.mFrame; + artifact.mRetirementCount = ledger.mRetirementCount + 1; + artifact.mRetiredResource = inputs.mHandles.mOldImage; + artifact.mRetirementFrame = inputs.mFrame; + artifact.mOldResolvableBefore = ledger.mCurrentImage == inputs.mHandles.mOldImage; + artifact.mOldResolvableAfter = false; + artifact.mReplacementResolvableAfter = true; +} + +bool nontrivialArtifact(const TextureUploadArtifact& before, const TextureUploadArtifact& artifact, + const StreamingUploadInputs& inputs) +{ + if (!validateTextureUploadArtifact(artifact) || artifact.mMipRGBA8[0] != tightBottomLeftSource(inputs) || + artifact.mSampledRGBA8 != artifact.mMipRGBA8[1] || + !hasMultipleChangedTexels(before.mSampledRGBA8, artifact.mSampledRGBA8)) + { + return false; + } + for (std::size_t mip = 0; mip < TEXTURE_UPLOAD_MIP_LEVELS; ++mip) + { + if (!hasMultipleChangedTexels(before.mMipRGBA8[mip], artifact.mMipRGBA8[mip])) + { + return false; + } + } + return true; +} + +bool publishedLifecycle(const LLRenderGLTextureUpload::LifecycleLedger& ledger, const StreamingUploadInputs& inputs, + const TextureUploadArtifact& artifact) +{ + return ledger.mCurrentImage == inputs.mHandles.mReplacementImage && ledger.mLastRevision == inputs.mRevision && + !ledger.mCompletionPending && ledger.mCompletionCount == 1 && + ledger.mCompletedDestination == inputs.mHandles.mReplacementImage && + ledger.mCompletedRevision == inputs.mRevision && ledger.mCompletedFrame == inputs.mFrame && + ledger.mRetirementCount == 1 && ledger.mRetiredResource == inputs.mHandles.mOldImage && + ledger.mRetirementFrame == inputs.mFrame && artifact.mPriorRevision == TEXTURE_UPLOAD_PRIOR_REVISION && + artifact.mRevision == inputs.mRevision && artifact.mCompletionCount == ledger.mCompletionCount && + artifact.mCompletedDestination == ledger.mCompletedDestination && + artifact.mCompletedRevision == ledger.mCompletedRevision && artifact.mCompletedFrame == ledger.mCompletedFrame && + artifact.mRetirementCount == ledger.mRetirementCount && + artifact.mRetiredResource == ledger.mRetiredResource && artifact.mRetirementFrame == ledger.mRetirementFrame && + artifact.mOldResolvableBefore && !artifact.mOldResolvableAfter && artifact.mReplacementResolvableAfter; +} + +bool executeDirect(TextureResources& resources, const StreamingUploadInputs& inputs, TextureUploadArtifact& artifact) +{ + TextureUploadArtifact before; + if (!captureArtifactPixels(resources, before)) + { + return false; + } + + const std::vector tight = tightBottomLeftSource(inputs); + const LLPointer raw = rawImage(tight.data(), TEXTURE_UPLOAD_RESIDENT_WIDTH, + TEXTURE_UPLOAD_RESIDENT_HEIGHT); + clear_glerror(); + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glPixelStorei(GL_UNPACK_ROW_LENGTH, 0); + glPixelStorei(GL_UNPACK_SKIP_ROWS, 0); + glPixelStorei(GL_UNPACK_SKIP_PIXELS, 0); + if (!resources.mReplacementImage->createGLTexture(TEXTURE_UPLOAD_RESIDENT_DISCARD, raw) || + !setCanonicalTextureParameters(resources.mReplacementImage->getTexName()) || !sampleReplacement(resources)) + { + return false; + } + + TextureUploadArtifact completed; + if (!captureArtifactPixels(resources, completed)) + { + return false; + } + populateLifecycleEvidence(completed, inputs, resources.mLifecycle); + if (!nontrivialArtifact(before, completed, inputs) || glGetError() != GL_NO_ERROR) + { + return false; + } + + resources.mLifecycle.mCurrentImage = inputs.mHandles.mReplacementImage; + resources.mLifecycle.mLastRevision = inputs.mRevision; + resources.mLifecycle.mCompletionPending = false; + resources.mLifecycle.mCompletionCount = completed.mCompletionCount; + resources.mLifecycle.mCompletedDestination = completed.mCompletedDestination; + resources.mLifecycle.mCompletedRevision = completed.mCompletedRevision; + resources.mLifecycle.mCompletedFrame = completed.mCompletedFrame; + resources.mLifecycle.mRetirementCount = completed.mRetirementCount; + resources.mLifecycle.mRetiredResource = completed.mRetiredResource; + resources.mLifecycle.mRetirementFrame = completed.mRetirementFrame; + artifact = std::move(completed); + return true; +} + +bool registerResources(LLRenderGLTextureUpload::Registry& registry, TextureResources& resources, + const StreamingUploadHandles& handles, + LLRenderGLTextureUpload::Sampler sampler = {}) +{ + sampler.mName = resources.mSampler; + return registry.addScreenTriangle(handles.mScreenTriangle, resources.mScreenTriangle) && + registry.addImageGenerations(handles.mOldImage, resources.mOldImage, handles.mReplacementImage, + resources.mReplacementImage) && + registry.addOutput(handles.mOutput, &resources.mOutput) && registry.addSampler(handles.mSampler, sampler) && + registry.addPipeline(handles.mPipeline, { TEXTURE_UPLOAD_PROGRAM, 0 }, &gCopyProgram) && + registry.addLifecycle(&resources.mLifecycle); +} + +using FrameMutation = std::function; + +std::vector> packetRejections() +{ + return { + { "frame", [](FrameSnapshot& frame) { frame.mFrame = 0; } }, + { "destination", [](FrameSnapshot& frame) { ++frame.mUploads[0].mDestination.mGeneration; } }, + { "old_generation", [](FrameSnapshot& frame) { ++frame.mImages[0].mHandle.mGeneration; } }, + { "output_generation", [](FrameSnapshot& frame) { ++frame.mImages[2].mHandle.mGeneration; } }, + { "image_alias", [](FrameSnapshot& frame) { frame.mImages[2].mHandle = frame.mImages[0].mHandle; } }, + { "old_mip_count", [](FrameSnapshot& frame) { --frame.mImages[0].mMipLevels; } }, + { "replacement_mip_count", [](FrameSnapshot& frame) { --frame.mImages[1].mMipLevels; } }, + { "output_mip_count", [](FrameSnapshot& frame) { ++frame.mImages[2].mMipLevels; } }, + { "revision", [](FrameSnapshot& frame) { frame.mUploads[0].mRevision = TEXTURE_UPLOAD_PRIOR_REVISION; } }, + { "subresource", [](FrameSnapshot& frame) { frame.mUploads[0].mSubresource.mMipLevel = 1; } }, + { "offset", [](FrameSnapshot& frame) { frame.mUploads[0].mOffset.mX = 1; } }, + { "extent", [](FrameSnapshot& frame) { --frame.mUploads[0].mExtent.mWidth; } }, + { "logical_extent", [](FrameSnapshot& frame) { --frame.mUploads[0].mLogicalExtent.mWidth; } }, + { "discard", [](FrameSnapshot& frame) { --frame.mUploads[0].mResidentDiscard; } }, + { "format", [](FrameSnapshot& frame) { frame.mUploads[0].mSourceFormat = PixelFormat::RGB8Unorm; } }, + { "row_pitch", [](FrameSnapshot& frame) { --frame.mUploads[0].mRowPitch; } }, + { "row_origin", [](FrameSnapshot& frame) { frame.mUploads[0].mRowOrigin = RowOrigin::BottomLeft; } }, + { "mip_policy", [](FrameSnapshot& frame) { frame.mUploads[0].mMipGeneration = MipGeneration::Disabled; } }, + { "pixel_offset", [](FrameSnapshot& frame) { frame.mUploads[0].mPixels.mOffset = 1; } }, + { "pixel_size", [](FrameSnapshot& frame) { --frame.mUploads[0].mPixels.mSize; } }, + { "before_state", [](FrameSnapshot& frame) { frame.mUploads[0].mBefore = ImageState::ShaderRead; } }, + { "during_state", [](FrameSnapshot& frame) { frame.mUploads[0].mDuring = ImageState::ShaderRead; } }, + { "after_state", [](FrameSnapshot& frame) { frame.mUploads[0].mAfter = ImageState::ColorAttachment; } }, + { "sampler", [](FrameSnapshot& frame) { frame.mSamplers[0].mAddressU = AddressMode::Repeat; } }, + { "missing_sampler", [](FrameSnapshot& frame) { frame.mSamplers.clear(); } }, + { "pipeline", [](FrameSnapshot& frame) { frame.mPipelines[0].mProgram.mVariant = 1; } }, + { "missing_pipeline", [](FrameSnapshot& frame) { frame.mPipelines.clear(); } }, + { "output", [](FrameSnapshot& frame) { --frame.mImages[2].mExtent.mWidth; } }, + { "release", [](FrameSnapshot& frame) { frame.mReleases[0].mFrame++; } }, + { "released_resource", + [](FrameSnapshot& frame) { frame.mReleases[0].mResource = ResourceHandle{ frame.mImages[1].mHandle }; } }, + { "missing_release", [](FrameSnapshot& frame) { frame.mReleases.clear(); } }, + { "extra_pass", [](FrameSnapshot& frame) { frame.mPasses.push_back(frame.mPasses[0]); } } + }; +} + +bool setTextureParameter(LLImageGL& image, GLenum parameter, GLint value) +{ + if (!gGL.getTexUnit(0)->bindManual(LLTexUnit::TT_TEXTURE, image.getTexName(), true)) + { + return false; + } + glTexParameteri(GL_TEXTURE_2D, parameter, value); + return glGetError() == GL_NO_ERROR; +} + +bool textureParameterEquals(const LLImageGL& image, GLenum parameter, GLint expected) +{ + GLint active = 0; + GLint binding = 0; + GLint actual = 0; + glGetIntegerv(GL_ACTIVE_TEXTURE, &active); + glActiveTexture(GL_TEXTURE0); + glGetIntegerv(GL_TEXTURE_BINDING_2D, &binding); + glBindTexture(GL_TEXTURE_2D, image.getTexName()); + glGetTexParameteriv(GL_TEXTURE_2D, parameter, &actual); + glBindTexture(GL_TEXTURE_2D, static_cast(binding)); + glActiveTexture(static_cast(active)); + return actual == expected && glGetError() == GL_NO_ERROR; +} + +bool redefineTextureLevel(GLuint name, GLint level, GLint internal_format, GLsizei width, GLsizei height, + GLenum format) +{ + if (!gGL.getTexUnit(0)->bindManual(LLTexUnit::TT_TEXTURE, name, true)) + { + return false; + } + const std::size_t components = format == GL_RGB ? 3 : 4; + std::vector pixels(static_cast(width) * height * components); + for (std::size_t index = 0; index < pixels.size(); ++index) + { + pixels[index] = static_cast(0x31u + ((index * 37u) % 0xbdu)); + } + glBindBuffer(GL_PIXEL_UNPACK_BUFFER, 0); + glPixelStorei(GL_UNPACK_ALIGNMENT, 1); + glTexImage2D(GL_TEXTURE_2D, level, internal_format, width, height, 0, format, GL_UNSIGNED_BYTE, pixels.data()); + return glGetError() == GL_NO_ERROR; +} + +bool textureLevelEquals(GLuint name, GLint level, GLenum parameter, GLint expected) +{ + if (!gGL.getTexUnit(0)->bindManual(LLTexUnit::TT_TEXTURE, name, true)) + { + return false; + } + GLint actual = 0; + glGetTexLevelParameteriv(GL_TEXTURE_2D, level, parameter, &actual); + return actual == expected && glGetError() == GL_NO_ERROR; +} + +bool samplerParameterEquals(GLuint sampler, GLenum parameter, GLint expected) +{ + GLint actual = 0; + glGetSamplerParameteriv(sampler, parameter, &actual); + return actual == expected && glGetError() == GL_NO_ERROR; +} + +struct LiveRejection +{ + const char* mName; + std::function mPoison; + std::function mStillPoisoned; + std::function mCleanup; +}; + +std::vector liveRejections() +{ + const auto no_cleanup = [](TextureResources&) {}; + return { + { "published_generation", + [](TextureResources& resources) + { + resources.mLifecycle.mCurrentImage = StreamingUploadHandles{}.mReplacementImage; + return true; + }, + [](TextureResources& resources) + { return resources.mLifecycle.mCurrentImage == StreamingUploadHandles{}.mReplacementImage; }, no_cleanup }, + { "duplicate_revision", + [](TextureResources& resources) + { + resources.mLifecycle.mLastRevision = TEXTURE_UPLOAD_REVISION; + return true; + }, + [](TextureResources& resources) { return resources.mLifecycle.mLastRevision == TEXTURE_UPLOAD_REVISION; }, + no_cleanup }, + { "regressing_revision", + [](TextureResources& resources) + { + resources.mLifecycle.mLastRevision = TEXTURE_UPLOAD_REVISION + 1; + return true; + }, + [](TextureResources& resources) { return resources.mLifecycle.mLastRevision == TEXTURE_UPLOAD_REVISION + 1; }, + no_cleanup }, + { "completion_pending", + [](TextureResources& resources) + { + resources.mLifecycle.mCompletionPending = true; + return true; + }, + [](TextureResources& resources) { return resources.mLifecycle.mCompletionPending; }, no_cleanup }, + { "existing_completion", + [](TextureResources& resources) + { + resources.mLifecycle.mCompletionCount = 1; + return true; + }, + [](TextureResources& resources) { return resources.mLifecycle.mCompletionCount == 1; }, no_cleanup }, + { "active_texture_cache_mismatch", + [](TextureResources&) + { + gGL.getTexUnit(1)->activate(); + glActiveTexture(GL_TEXTURE0); + return glGetError() == GL_NO_ERROR; + }, + [](TextureResources&) + { + GLint active = 0; + glGetIntegerv(GL_ACTIVE_TEXTURE, &active); + return active == GL_TEXTURE0 && gGL.getCurrentTexUnitIndex() == 1 && glGetError() == GL_NO_ERROR; + }, + [](TextureResources&) { gGL.getTexUnit(0)->activate(); } }, + { "shader_profiling", + [](TextureResources&) + { + LLGLSLShader::sProfileEnabled = true; + return true; + }, + [](TextureResources&) { return LLGLSLShader::sProfileEnabled; }, + [](TextureResources&) { LLGLSLShader::sProfileEnabled = false; } }, + { "completion_destination", + [](TextureResources& resources) + { + resources.mLifecycle.mCompletedDestination = StreamingUploadHandles{}.mReplacementImage; + return true; + }, + [](TextureResources& resources) + { return resources.mLifecycle.mCompletedDestination == StreamingUploadHandles{}.mReplacementImage; }, + no_cleanup }, + { "completion_revision", + [](TextureResources& resources) + { + resources.mLifecycle.mCompletedRevision = TEXTURE_UPLOAD_REVISION; + return true; + }, + [](TextureResources& resources) + { return resources.mLifecycle.mCompletedRevision == TEXTURE_UPLOAD_REVISION; }, no_cleanup }, + { "completion_frame", + [](TextureResources& resources) + { + resources.mLifecycle.mCompletedFrame = TEXTURE_UPLOAD_DIAGNOSTIC_FRAME; + return true; + }, + [](TextureResources& resources) + { return resources.mLifecycle.mCompletedFrame == TEXTURE_UPLOAD_DIAGNOSTIC_FRAME; }, no_cleanup }, + { "existing_retirement", + [](TextureResources& resources) + { + resources.mLifecycle.mRetirementCount = 1; + return true; + }, + [](TextureResources& resources) { return resources.mLifecycle.mRetirementCount == 1; }, no_cleanup }, + { "retired_resource", + [](TextureResources& resources) + { + resources.mLifecycle.mRetiredResource = StreamingUploadHandles{}.mOldImage; + return true; + }, + [](TextureResources& resources) + { return resources.mLifecycle.mRetiredResource == StreamingUploadHandles{}.mOldImage; }, no_cleanup }, + { "retirement_frame", + [](TextureResources& resources) + { + resources.mLifecycle.mRetirementFrame = TEXTURE_UPLOAD_DIAGNOSTIC_FRAME; + return true; + }, + [](TextureResources& resources) + { return resources.mLifecycle.mRetirementFrame == TEXTURE_UPLOAD_DIAGNOSTIC_FRAME; }, no_cleanup }, + { "replacement_format", + [](TextureResources& resources) + { + resources.mReplacementImage->setExplicitFormat(GL_RGB8, GL_RGB, GL_UNSIGNED_BYTE, false); + return true; + }, + [](TextureResources& resources) { return resources.mReplacementImage->getPrimaryFormat() == GL_RGB; }, + no_cleanup }, + { "live_name_alias", + [](TextureResources& resources) + { + resources.mSavedReplacementName = resources.mReplacementImage->getTexName(); + resources.mReplacementImage->setTexName(resources.mOldImage->getTexName()); + return resources.mSavedReplacementName != 0; + }, + [](TextureResources& resources) + { return resources.mReplacementImage->getTexName() == resources.mOldImage->getTexName(); }, + [](TextureResources& resources) + { + resources.mReplacementImage->setTexName(resources.mSavedReplacementName); + resources.mSavedReplacementName = 0; + } }, + { "old_level_format", + [](TextureResources& resources) + { + const GLuint name = resources.mOldImage->getTexName(); + return redefineTextureLevel(name, 0, GL_RGB8, 8, 4, GL_RGB) && + redefineTextureLevel(name, 1, GL_RGB8, 4, 2, GL_RGB) && + redefineTextureLevel(name, 2, GL_RGB8, 2, 1, GL_RGB); + }, + [](TextureResources& resources) + { + const GLuint name = resources.mOldImage->getTexName(); + return textureLevelEquals(name, 0, GL_TEXTURE_INTERNAL_FORMAT, GL_RGB8) && + textureLevelEquals(name, 1, GL_TEXTURE_INTERNAL_FORMAT, GL_RGB8) && + textureLevelEquals(name, 2, GL_TEXTURE_INTERNAL_FORMAT, GL_RGB8); + }, no_cleanup }, + { "replacement_level_extent", + [](TextureResources& resources) + { + return redefineTextureLevel(resources.mReplacementImage->getTexName(), 2, GL_RGBA8, 1, 1, GL_RGBA); + }, + [](TextureResources& resources) + { return textureLevelEquals(resources.mReplacementImage->getTexName(), 2, GL_TEXTURE_WIDTH, 1); }, + no_cleanup }, + { "output_level_format", + [](TextureResources& resources) + { + return redefineTextureLevel(resources.mOutput.getTexture(0), 0, GL_RGB8, + TEXTURE_UPLOAD_OUTPUT_WIDTH, TEXTURE_UPLOAD_OUTPUT_HEIGHT, GL_RGB); + }, + [](TextureResources& resources) + { + return textureLevelEquals(resources.mOutput.getTexture(0), 0, GL_TEXTURE_INTERNAL_FORMAT, GL_RGB8); + }, no_cleanup }, + { "replacement_mips", + [](TextureResources& resources) + { return setTextureParameter(*resources.mReplacementImage, GL_TEXTURE_MAX_LEVEL, 1); }, + [](TextureResources& resources) + { return textureParameterEquals(*resources.mReplacementImage, GL_TEXTURE_MAX_LEVEL, 1); }, no_cleanup }, + { "old_mips", [](TextureResources& resources) + { return setTextureParameter(*resources.mOldImage, GL_TEXTURE_MAX_LEVEL, 1); }, + [](TextureResources& resources) { return textureParameterEquals(*resources.mOldImage, GL_TEXTURE_MAX_LEVEL, 1); }, + no_cleanup }, + { "output_base_level", + [](TextureResources& resources) + { + if (!gGL.getTexUnit(0)->bindManual(resources.mOutput.getUsage(), resources.mOutput.getTexture(0))) return false; + glTexParameteri(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, 1); + return glGetError() == GL_NO_ERROR; + }, + [](TextureResources& resources) + { + GLint value = 0; + if (!gGL.getTexUnit(0)->bindManual(resources.mOutput.getUsage(), resources.mOutput.getTexture(0))) return false; + glGetTexParameteriv(GL_TEXTURE_2D, GL_TEXTURE_BASE_LEVEL, &value); + return value == 1 && glGetError() == GL_NO_ERROR; + }, no_cleanup }, + { "sampler_wrap", + [](TextureResources& resources) + { + glSamplerParameteri(resources.mSampler, GL_TEXTURE_WRAP_S, GL_REPEAT); + return glGetError() == GL_NO_ERROR; + }, + [](TextureResources& resources) + { return samplerParameterEquals(resources.mSampler, GL_TEXTURE_WRAP_S, GL_REPEAT); }, no_cleanup }, + { "sampler_filter", + [](TextureResources& resources) + { + glSamplerParameteri(resources.mSampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST); + return glGetError() == GL_NO_ERROR; + }, + [](TextureResources& resources) + { return samplerParameterEquals(resources.mSampler, GL_TEXTURE_MIN_FILTER, GL_NEAREST); }, no_cleanup }, + { "program_identity", [](TextureResources&) { gCopyProgram.mName = "Rejected Copy Shader"; return true; }, + [](TextureResources&) { return gCopyProgram.mName == "Rejected Copy Shader"; }, + [](TextureResources&) { gCopyProgram.mName = "Copy Shader"; } }, + { "screen_buffer_allocation", + [](TextureResources& resources) + { + gCopyProgram.bind(); + resources.mScreenTriangle->setBuffer(); + glBufferData(GL_ARRAY_BUFFER, 32, nullptr, GL_STATIC_DRAW); + const bool success = glGetError() == GL_NO_ERROR; + gCopyProgram.unbind(); + return success && glGetError() == GL_NO_ERROR; + }, + [](TextureResources& resources) + { + gCopyProgram.bind(); + resources.mScreenTriangle->setBuffer(); + GLint size = 0; + glGetBufferParameteriv(GL_ARRAY_BUFFER, GL_BUFFER_SIZE, &size); + const bool poisoned = size == 32 && glGetError() == GL_NO_ERROR; + gCopyProgram.unbind(); + return poisoned && glGetError() == GL_NO_ERROR; + }, + [](TextureResources& resources) + { + gCopyProgram.bind(); + resources.mScreenTriangle->setBuffer(); + glBufferData(GL_ARRAY_BUFFER, 48, nullptr, GL_STATIC_DRAW); + gCopyProgram.unbind(); + clear_glerror(); + } }, + { "output_bound", [](TextureResources& resources) { resources.mOutput.bindTarget(); return true; }, + [](TextureResources& resources) { return resources.mOutput.isBoundInStack(); }, + [](TextureResources& resources) { if (resources.mOutput.isBoundInStack()) resources.mOutput.flush(); } } + }; +} + +} // namespace + +bool run() +{ + if (!gGLManager.mInited || gGLManager.mGLVersion < 4.09f) + { + return emitFailure("unsupported_gl_version"); + } + const TextureUploadFixture fixture = makeTextureUploadFixture(); + const TextureUploadCase upload_case = makeTextureUploadCase(); + const auto decoded = decodeStreamingUploadFrame(upload_case.mFrame); + if (!decoded) + { + return emitFailure("fixture_decode"); + } + + TextureResources direct_resources; + if (!initializeResources(direct_resources, fixture)) + { + return emitFailure("direct_resources"); + } + TextureUploadArtifact direct_artifact; + if (!executeDirect(direct_resources, *decoded, direct_artifact)) + { + return emitFailure("direct_execution"); + } + if (!publishedLifecycle(direct_resources.mLifecycle, *decoded, direct_artifact)) + { + return emitFailure("direct_lifecycle"); + } + + TextureResources contract_resources; + if (!initializeResources(contract_resources, fixture)) + { + return emitFailure("contract_resources"); + } + LLRenderGLTextureUpload::Registry registry; + if (!registerResources(registry, contract_resources, upload_case.mInputs.mHandles)) + { + return emitFailure("contract_registry"); + } + + std::size_t rejection_failures = 0; + std::string first_failure; + + TextureSnapshot off_main_before; + TextureSnapshot off_main_after; + TextureUploadArtifact off_main_result; + off_main_result.mSampledRGBA8 = { 0x5a }; + const TextureUploadArtifact off_main_result_before = off_main_result; + const bool off_main_snapped_before = snapshot(contract_resources, off_main_before); + bool off_main_accepted = true; + std::thread off_main_worker( + [&] + { + off_main_accepted = LLRenderGLTextureUpload::execute(upload_case.mFrame, registry, off_main_result); + }); + off_main_worker.join(); + const bool off_main_snapped_after = snapshot(contract_resources, off_main_after); + if (!off_main_snapped_before || !off_main_snapped_after || off_main_accepted || + off_main_result != off_main_result_before || off_main_before != off_main_after || glGetError() != GL_NO_ERROR) + { + ++rejection_failures; + first_failure = "off_main_thread_rejection"; + } + + for (const auto& [name, mutate] : packetRejections()) + { + TextureSnapshot before; + TextureSnapshot after; + if (!snapshot(contract_resources, before)) + { + ++rejection_failures; + if (first_failure.empty()) first_failure = std::string("snapshot_before ") + name; + continue; + } + FrameSnapshot rejected = upload_case.mFrame; + mutate(rejected); + TextureUploadArtifact rejected_result = makeTextureUploadArtifact(); + const TextureUploadArtifact result_before = rejected_result; + const bool accepted = LLRenderGLTextureUpload::execute(rejected, registry, rejected_result); + if (!snapshot(contract_resources, after) || accepted || rejected_result != result_before || before != after || + glGetError() != GL_NO_ERROR) + { + ++rejection_failures; + if (first_failure.empty()) first_failure = std::string("rejection ") + name; + } + } + + for (const LiveRejection& rejection : liveRejections()) + { + TextureResources rejected_resources; + LLRenderGLTextureUpload::Registry rejected_registry; + TextureSnapshot before; + TextureSnapshot after; + TextureUploadArtifact rejected_result; + rejected_result.mSampledRGBA8 = { 0x5a }; + const TextureUploadArtifact result_before = rejected_result; + + clear_glerror(); + const bool initialized = initializeResources(rejected_resources, fixture) && + registerResources(rejected_registry, rejected_resources, decoded->mHandles); + const bool poisoned = initialized && rejection.mPoison(rejected_resources); + const bool snapped_before = poisoned && snapshot(rejected_resources, before); + const bool accepted = snapped_before && + LLRenderGLTextureUpload::execute(upload_case.mFrame, rejected_registry, rejected_result); + const bool snapped_after = initialized && snapshot(rejected_resources, after); + const bool poison_unchanged = poisoned && rejection.mStillPoisoned(rejected_resources); + const bool gl_clean = glGetError() == GL_NO_ERROR; + if (poisoned) rejection.mCleanup(rejected_resources); + gGL.getTexUnit(0)->disable(); + + if (!initialized || !poisoned || !snapped_before || !snapped_after || accepted || + rejected_result != result_before || before != after || !poison_unchanged || !gl_clean) + { + ++rejection_failures; + if (first_failure.empty()) first_failure = std::string("live_rejection ") + rejection.mName; + } + } + + std::array, TEXTURE_UPLOAD_MIP_LEVELS> old_before; + std::array, TEXTURE_UPLOAD_MIP_LEVELS> old_after; + TextureUploadArtifact contract_before; + TextureUploadArtifact contract_artifact; + std::string contract_error; + const bool old_read_before = readImage(*contract_resources.mOldImage, old_before) && + captureArtifactPixels(contract_resources, contract_before); + AmbientPoisonBuffers ambient_buffers; + AmbientGLState ambient_before; + AmbientGLState ambient_after; + const bool ambient_poisoned = poisonAmbientGLState(direct_resources, ambient_buffers) && + captureAmbientGLState(ambient_before); + const bool executed = rejection_failures == 0 && old_read_before && ambient_poisoned && + LLRenderGLTextureUpload::execute(upload_case.mFrame, registry, contract_artifact, + &contract_error); + const bool ambient_captured_after = ambient_poisoned && captureAmbientGLState(ambient_after); + const bool ambient_state_restored = ambient_poisoned && ambient_captured_after && ambient_before == ambient_after; + cleanupAmbientGLState(direct_resources, ambient_buffers); + const bool old_read_after = readImage(*contract_resources.mOldImage, old_after); + const bool contract_success = old_read_before && executed && ambient_state_restored && old_read_after && + old_before == old_after && + nontrivialArtifact(contract_before, contract_artifact, *decoded) && + publishedLifecycle(contract_resources.mLifecycle, *decoded, contract_artifact) && + !registry.isResolvable(decoded->mHandles.mOldImage) && + registry.isResolvable(decoded->mHandles.mReplacementImage); + const TextureUploadComparisonStats comparison = + compareTextureUploadArtifacts(direct_artifact, contract_artifact); + bool success = contract_success && comparison.mComparable && comparison.mMatch; + + if (first_failure.empty() && !old_read_before) first_failure = "old_read_before"; + if (first_failure.empty() && !ambient_poisoned) first_failure = "ambient_state_poison"; + if (first_failure.empty() && !executed) + { + first_failure = contract_error.empty() ? "contract_execution" : "contract_execution " + contract_error; + } + if (first_failure.empty() && !ambient_state_restored) first_failure = "ambient_state_restore"; + if (first_failure.empty() && !old_read_after) first_failure = "old_read_after"; + if (first_failure.empty() && old_read_before && old_read_after && old_before != old_after) + { + first_failure = "old_generation_mutated"; + } + if (first_failure.empty() && executed && !nontrivialArtifact(contract_before, contract_artifact, *decoded)) + { + first_failure = "contract_artifact_trivial"; + } + if (first_failure.empty() && executed && + (!publishedLifecycle(contract_resources.mLifecycle, *decoded, contract_artifact) || + registry.isResolvable(decoded->mHandles.mOldImage) || + !registry.isResolvable(decoded->mHandles.mReplacementImage))) + { + first_failure = "contract_lifecycle"; + } + if (first_failure.empty() && !comparison.mComparable) + { + first_failure = "artifact_compare " + comparison.mError; + } + if (first_failure.empty() && !comparison.mMatch) + { + first_failure = "artifact_mismatch"; + } + + const std::string artifact_path = gSavedSettings.getString("RenderTextureUploadArtifactPath"); + bool artifact_written = false; + if (success && !artifact_path.empty()) + { + std::string artifact_error; + artifact_written = writeTextureUploadArtifact(artifact_path, direct_artifact, &artifact_error); + if (!artifact_written) + { + success = false; + if (first_failure.empty()) first_failure = "artifact_write"; + } + else if (!artifact_error.empty()) + { + LL_WARNS("RenderContractParity") << artifact_error << LL_ENDL; + } + } + + std::ostringstream result; + result << "TEXTURE_UPLOAD_CONTRACT_PARITY result=" << (success ? "pass" : "fail") + << " resident=8x4 logical=32x16 discard=2 mips=3 sampled=4x2" + << " mip_bytes=" << comparison.mComparedMipBytes + << " sample_bytes=" << comparison.mComparedSampleBytes + << " mismatches=" << comparison.mMismatchCount + << " rejection_cases=" << 1 + packetRejections().size() + liveRejections().size() + << " rejection_failures=" << rejection_failures + << " completions=" << contract_artifact.mCompletionCount + << " retirements=" << contract_artifact.mRetirementCount + << " artifact=" << (artifact_path.empty() ? "disabled" : artifact_written ? "written" : "failed"); + if (!first_failure.empty()) + { + result << " first_failure={" << first_failure << '}'; + } + std::cout << result.str() << std::endl; + LL_INFOS("RenderContractParity") << result.str() << LL_ENDL; + return success; +} + +} // namespace LLTextureUploadParity diff --git a/indra/newview/lltextureuploadparity.h b/indra/newview/lltextureuploadparity.h new file mode 100644 index 00000000000..52c2deb1f03 --- /dev/null +++ b/indra/newview/lltextureuploadparity.h @@ -0,0 +1,26 @@ +/** + * @file lltextureuploadparity.h + * @brief Account-free parity harness for one streaming texture upload. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#ifndef LL_LLTEXTUREUPLOADPARITY_H +#define LL_LLTEXTUREUPLOADPARITY_H + +namespace LLTextureUploadParity +{ + +bool run(); + +} + +#endif diff --git a/indra/newview/llviewerdisplay.cpp b/indra/newview/llviewerdisplay.cpp index 3f06fe33206..0a1fb02b286 100644 --- a/indra/newview/llviewerdisplay.cpp +++ b/indra/newview/llviewerdisplay.cpp @@ -50,6 +50,7 @@ #include "llglheaders.h" #include "llgltfmateriallist.h" #include "llhudmanager.h" +#include "llimagegl.h" #include "llimagepng.h" #include "llmachineid.h" #include "llmemory.h" @@ -58,6 +59,7 @@ #include "llrender.h" #include "llscenemonitor.h" #include "llsdjson.h" +#include "llshadermgr.h" #include "llselectmgr.h" #include "llsky.h" #include "llspatialpartition.h" @@ -80,6 +82,7 @@ #include "llviewerregion.h" #include "llviewershadermgr.h" #include "llviewertexturelist.h" +#include "llviewerstats.h" #include "llviewerwindow.h" #include "llvoavatarself.h" #include "llvograss.h" @@ -582,6 +585,9 @@ void display(bool rebuild, F32 zoom_factor, int subfield, bool for_snapshot) gPipeline.mBackfaceCull = true; gFrameCount++; +#if defined(LL_RENDER_BENCHMARK) + LLTrace::sample(LLStatViewer::FRAME_NUMBER, (U64)gFrameCount); +#endif gRecentFrameCount++; if (gFocusMgr.getAppHasFocus()) { @@ -756,13 +762,41 @@ void display(bool rebuild, F32 zoom_factor, int subfield, bool for_snapshot) { LL_PROFILE_ZONE_NAMED_CATEGORY_DISPLAY("Update Geom"); const F32 max_geom_update_time = 0.005f*10.f*gFrameIntervalSeconds.value(); // 50 ms/second update time - gPipeline.createObjects(max_geom_update_time); - gPipeline.processPartitionQ(); - gPipeline.updateGeom(max_geom_update_time); + { +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_geometry_create_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_GEOMETRY_CREATE)); + (void)renderer_geometry_create_timer; +#endif + gPipeline.createObjects(max_geom_update_time); + } + { +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_partition_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_PARTITION)); + (void)renderer_partition_timer; +#endif + gPipeline.processPartitionQ(); + } + { +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_geometry_update_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_GEOMETRY_UPDATE)); + (void)renderer_geometry_update_timer; +#endif + gPipeline.updateGeom(max_geom_update_time); + } stop_glerror(); } - gPipeline.updateGL(); + { +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_gl_update_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_GEOMETRY_UPDATE)); + (void)renderer_gl_update_timer; +#endif + gPipeline.updateGL(); + } stop_glerror(); @@ -786,7 +820,14 @@ void display(bool rebuild, F32 zoom_factor, int subfield, bool for_snapshot) static LLCullResult result; LLViewerCamera::sCurCameraID = LLViewerCamera::CAMERA_WORLD; LLPipeline::sUnderWaterRender = LLViewerCamera::getInstance()->cameraUnderWater(); - gPipeline.updateCull(*LLViewerCamera::getInstance(), result); + { +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_cull_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_CULL)); + (void)renderer_cull_timer; +#endif + gPipeline.updateCull(*LLViewerCamera::getInstance(), result); + } stop_glerror(); LLGLState::checkStates(); @@ -808,6 +849,11 @@ void display(bool rebuild, F32 zoom_factor, int subfield, bool for_snapshot) if (!for_snapshot) { +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_shadow_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_SHADOWS)); + (void)renderer_shadow_timer; +#endif if (gFrameCount > 1 && !for_snapshot) { //for some reason, ATI 4800 series will error out if you //try to generate a shadow before the first frame is through @@ -848,6 +894,11 @@ void display(bool rebuild, F32 zoom_factor, int subfield, bool for_snapshot) { LL_PROFILE_ZONE_NAMED("Update Images"); +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_texture_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_TEXTURE_WORK)); + (void)renderer_texture_timer; +#endif { LL_PROFILE_ZONE_NAMED_CATEGORY_DISPLAY("Class"); @@ -886,12 +937,22 @@ void display(bool rebuild, F32 zoom_factor, int subfield, bool for_snapshot) LLAppViewer::instance()->pingMainloopTimeout("Display:StateSort"); { LL_PROFILE_ZONE_NAMED_CATEGORY_DISPLAY("display - 4") +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_state_sort_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_STATE_SORT)); + (void)renderer_state_sort_timer; +#endif LLViewerCamera::sCurCameraID = LLViewerCamera::CAMERA_WORLD; gPipeline.stateSort(*LLViewerCamera::getInstance(), result); stop_glerror(); if (rebuild) { +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_rebuild_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_REBUILD)); + (void)renderer_rebuild_timer; +#endif ////////////////////////////////////// // // rebuildPools @@ -987,6 +1048,11 @@ void display(bool rebuild, F32 zoom_factor, int subfield, bool for_snapshot) && !gRestoreGL) { LL_PROFILE_ZONE_NAMED_CATEGORY_DISPLAY("display - 5") +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_submission_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_SUBMISSION)); + (void)renderer_submission_timer; +#endif LLViewerCamera::sCurCameraID = LLViewerCamera::CAMERA_WORLD; static LLCachedControl render_depth_pre_pass(gSavedSettings, "RenderDepthPrePass", false); @@ -1034,6 +1100,11 @@ void display(bool rebuild, F32 zoom_factor, int subfield, bool for_snapshot) if (LLPipeline::sRenderDeferred) { +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_lighting_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_LIGHTING)); + (void)renderer_lighting_timer; +#endif gPipeline.renderDeferredLighting(); } @@ -1062,6 +1133,18 @@ void display(bool rebuild, F32 zoom_factor, int subfield, bool for_snapshot) display_stats(); +#if defined(LL_RENDER_BENCHMARK) + LLTrace::sample(LLStatViewer::TEXTURE_UPLOAD_COUNT, LLImageGL::getTextureUploadCount()); + LLTrace::sample(LLStatViewer::TEXTURE_UPLOAD_BYTES, LLImageGL::getTextureUploadBytes()); + LLTrace::sample(LLStatViewer::TEXTURE_READBACK_COUNT, LLImageGL::getTextureReadbackCount()); + LLTrace::sample(LLStatViewer::TEXTURE_READBACK_TIME_US, LLImageGL::getTextureReadbackTimeUS()); + LLTrace::sample(LLStatViewer::TEXTURE_WAIT_COUNT, LLImageGL::getTextureWaitCount()); + LLTrace::sample(LLStatViewer::TEXTURE_WAIT_TIME_US, LLImageGL::getTextureWaitTimeUS()); + LLTrace::sample(LLStatViewer::SHADER_COMPILE_COUNT, LLShaderMgr::getShaderCompileCount()); + LLTrace::sample(LLStatViewer::SHADER_COMPILE_TIME_US, LLShaderMgr::getShaderCompileTimeUS()); + LLTrace::sample(LLStatViewer::SHADER_BIND_COUNT, LLGLSLShader::getShaderBindCount()); +#endif + LLAppViewer::instance()->pingMainloopTimeout("Display:Done"); gShiftFrame = false; @@ -1467,6 +1550,11 @@ bool setup_hud_matrices(const LLRect& screen_region) void render_ui(F32 zoom_factor, int subfield) { LLPerfStats::RecordSceneTime T ( LLPerfStats::StatType_t::RENDER_UI ); // render time capture - Primary UI stat can have HUD time overlap (TODO) +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_ui_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_UI)); + (void)renderer_ui_timer; +#endif LL_PROFILE_ZONE_SCOPED_CATEGORY_UI; //LL_RECORD_BLOCK_TIME(FTM_RENDER_UI); LL_PROFILE_GPU_ZONE("ui"); LLGLState::checkStates(); @@ -1552,6 +1640,11 @@ void render_ui(F32 zoom_factor, int subfield) void swap() { LLPerfStats::RecordSceneTime T ( LLPerfStats::StatType_t::RENDER_SWAP ); // render time capture - Swap buffer time - can signify excessive data transfer to/from GPU +#if defined(LL_RENDER_BENCHMARK) + const LLTrace::BlockTimer& renderer_swap_timer( + LLTrace::timeThisBlock(LLStatViewer::RENDER_SWAP)); + (void)renderer_swap_timer; +#endif LL_PROFILE_ZONE_NAMED_CATEGORY_DISPLAY("Swap"); LL_PROFILE_GPU_ZONE("swap"); if (gDisplaySwapBuffers) diff --git a/indra/newview/llviewerstats.cpp b/indra/newview/llviewerstats.cpp index 742ff95f265..3e66565dc97 100644 --- a/indra/newview/llviewerstats.cpp +++ b/indra/newview/llviewerstats.cpp @@ -234,6 +234,33 @@ LLTrace::SampleStatHandle FRAMETIME_JITTER_EVENTS("frametimeevents", "Numbe LLTrace::SampleStatHandle DOFRAME_TIME_US("doframetimeus", "doFrame wall time in microseconds."); +#if defined(LL_RENDER_BENCHMARK) +LLTrace::SampleStatHandle FRAME_NUMBER("rendererframenumber", "Rendered frame sequence number"), + TEXTURE_UPLOAD_COUNT("renderertextureuploadcount", "Cumulative texture upload count"), + TEXTURE_UPLOAD_BYTES("renderertextureuploadbytes", "Cumulative texture upload bytes"), + TEXTURE_READBACK_COUNT("renderertexturereadbackcount", "Cumulative texture readback count"), + TEXTURE_READBACK_TIME_US("renderertexturereadbacktimeus", "Cumulative CPU time in texture readbacks"), + TEXTURE_WAIT_COUNT("renderertexturewaitcount", "Cumulative explicit texture synchronization count"), + TEXTURE_WAIT_TIME_US("renderertexturewaittimeus", "Cumulative CPU time in explicit texture synchronization"), + SHADER_COMPILE_COUNT("renderershadercompilecount", "Cumulative shader compile count"), + SHADER_COMPILE_TIME_US("renderershadercompiletimeus", "Cumulative CPU time in shader compilation"), + SHADER_BIND_COUNT("renderershaderbindcount", "Cumulative shader program change count"); + +LLTrace::BlockTimerStatHandle RENDER_GEOMETRY_CREATE("Renderer geometry create"), + RENDER_PARTITION("Renderer partition processing"), + RENDER_GEOMETRY_UPDATE("Renderer geometry update"), + RENDER_CULL("Renderer cull"), + RENDER_SHADOWS("Renderer shadows and impostors"), + RENDER_TEXTURE_WORK("Renderer texture work"), + RENDER_STATE_SORT("Renderer state sort"), + RENDER_REBUILD("Renderer pool rebuild"), + RENDER_SUBMISSION("Renderer GL submission"), + RENDER_LIGHTING("Renderer deferred lighting"), + RENDER_UI("Renderer UI and HUD"), + RENDER_SWAP("Renderer present"), + RENDER_IDLE("Renderer idle update"); +#endif + LLTrace::SampleStatHandle NOTRMALIZED_FRAMETIME_JITTER_SESSION("normalizedframetimejitter", "Normalized frametime jitter over the session."); LLTrace::SampleStatHandle NFTV("nftv", "Normalized frametime variation."); LLTrace::SampleStatHandle NORMALIZED_FRAMTIME_JITTER_PERIOD("normalizedframetimejitterperiod", "Normalized frametime jitter over the last 5 seconds."); diff --git a/indra/newview/llviewerstats.h b/indra/newview/llviewerstats.h index 7da74e716b5..a2ff0fde625 100644 --- a/indra/newview/llviewerstats.h +++ b/indra/newview/llviewerstats.h @@ -225,6 +225,38 @@ extern LLTrace::SampleStatHandle FRAMETIME_JITTER_EVENTS, extern LLTrace::SampleStatHandle DOFRAME_TIME_US; +#if defined(LL_RENDER_BENCHMARK) +// Renderer benchmark samples. Resource values are monotonically increasing +// counters; consumers derive per-frame deltas after de-duplicating frames. +extern LLTrace::SampleStatHandle FRAME_NUMBER, + TEXTURE_UPLOAD_COUNT, + TEXTURE_UPLOAD_BYTES, + TEXTURE_READBACK_COUNT, + TEXTURE_READBACK_TIME_US, + TEXTURE_WAIT_COUNT, + TEXTURE_WAIT_TIME_US, + SHADER_COMPILE_COUNT, + SHADER_COMPILE_TIME_US, + SHADER_BIND_COUNT; + +// These scopes intentionally describe CPU work. GPU pass timings are gathered +// in a separate diagnostic capture so the steady benchmark never blocks on a +// timer-query result. +extern LLTrace::BlockTimerStatHandle RENDER_GEOMETRY_CREATE, + RENDER_PARTITION, + RENDER_GEOMETRY_UPDATE, + RENDER_CULL, + RENDER_SHADOWS, + RENDER_TEXTURE_WORK, + RENDER_STATE_SORT, + RENDER_REBUILD, + RENDER_SUBMISSION, + RENDER_LIGHTING, + RENDER_UI, + RENDER_SWAP, + RENDER_IDLE; +#endif + extern LLTrace::SampleStatHandle NOTRMALIZED_FRAMETIME_JITTER_SESSION; extern LLTrace::SampleStatHandle NFTV; extern LLTrace::SampleStatHandle NORMALIZED_FRAMTIME_JITTER_PERIOD; diff --git a/indra/newview/llviewerwindow.cpp b/indra/newview/llviewerwindow.cpp index 32af06cd0d9..ec5ab5f7801 100644 --- a/indra/newview/llviewerwindow.cpp +++ b/indra/newview/llviewerwindow.cpp @@ -26,6 +26,7 @@ #include "llviewerprecompiledheaders.h" #include "llviewerwindow.h" +#include "llbenchmarkdisplay.h" // system library includes @@ -262,6 +263,26 @@ static const char KEY_MOUSELOOK = 'M'; LLTrace::SampleStatHandle<> LLViewerWindow::sMouseVelocityStat("Mouse Velocity"); +#if defined(LL_RENDER_BENCHMARK) +namespace +{ +void applyBenchmarkUIScale(LLWindow* window) +{ + const F32 target_scale = gSavedSettings.getF32("RenderBenchmarkUIScale"); + const F32 current_scale = gSavedSettings.getF32("UIScaleFactor"); + F32 backing_scale_x = 0.f; + F32 backing_scale_y = 0.f; + window->getBackingScale(backing_scale_x, backing_scale_y); + const F32 configured_scale = LLBenchmarkDisplay::configuredUIScale( + current_scale, target_scale, backing_scale_x); + if (configured_scale != current_scale) + { + gSavedSettings.setF32("UIScaleFactor", configured_scale); + } +} +} +#endif + class RecordToChatConsoleRecorder : public LLError::Recorder { @@ -1977,9 +1998,14 @@ LLViewerWindow::LLViewerWindow(const Params& p) U32 max_core_count = gSavedSettings.getU32("EmulateCoreCount"); F32 max_gl_version = gSavedSettings.getF32("RenderMaxOpenGLVersion"); + U32 window_flags = (gSavedSettings.getBOOL("RenderTonemapContractParityTest") + || gSavedSettings.getBOOL("RenderMaterialContractParityTest") + || gSavedSettings.getBOOL("RenderTextureUploadContractParityTest")) + ? LLWindow::FLAG_CREATE_HIDDEN + : 0; mWindow = LLWindowManager::createWindow(this, - p.title, p.name, p.x, p.y, p.width, p.height, 0, + p.title, p.name, p.x, p.y, p.width, p.height, window_flags, p.fullscreen, gHeadlessClient, gSavedSettings.getBOOL("RenderVSyncEnable"), @@ -2047,6 +2073,10 @@ LLViewerWindow::LLViewerWindow(const Params& p) << LL_ENDL; } +#if defined(LL_RENDER_BENCHMARK) + applyBenchmarkUIScale(mWindow); +#endif + // Get the real window rect the window was created with (since there are various OS-dependent reasons why // the size of a window or fullscreen context may have been adjusted slightly...) F32 ui_scale_factor = llclamp(gSavedSettings.getF32("UIScaleFactor") * mWindow->getSystemUISize(), MIN_UI_SCALE, MAX_UI_SCALE); @@ -6087,6 +6117,9 @@ F32 LLViewerWindow::getWorldViewAspectRatio() const void LLViewerWindow::calcDisplayScale() { +#if defined(LL_RENDER_BENCHMARK) + applyBenchmarkUIScale(mWindow); +#endif F32 ui_scale_factor = llclamp(gSavedSettings.getF32("UIScaleFactor") * mWindow->getSystemUISize(), MIN_UI_SCALE, MAX_UI_SCALE); LLVector2 display_scale; display_scale.setVec(llmax(1.f / mWindow->getPixelAspectRatio(), 1.f), llmax(mWindow->getPixelAspectRatio(), 1.f)); diff --git a/indra/newview/pipeline.cpp b/indra/newview/pipeline.cpp index ab7058b5df1..7ae999ec70a 100644 --- a/indra/newview/pipeline.cpp +++ b/indra/newview/pipeline.cpp @@ -45,6 +45,8 @@ #include "llui.h" #include "llglheaders.h" #include "llrender.h" +#include "llrendergltonemap.h" +#include "lltonemapdiagnostic.h" #include "llstartup.h" #include "llwindow.h" // swapBuffers() @@ -69,6 +71,8 @@ #include "llhudnametag.h" #include "llhudtext.h" #include "lllightconstants.h" +#include "llmaterialparity.h" +#include "lltextureuploadparity.h" #include "llmeshrepository.h" #include "llpipelinelistener.h" #include "llresmgr.h" @@ -123,6 +127,15 @@ #include "SMAASearchTex.h" #include "llerror.h" +#include +#include +#include +#include +#include +#include +#include +#include + #if LL_CLANG #pragma clang diagnostic push #pragma clang diagnostic ignored "-Wunused-function" @@ -513,14 +526,18 @@ void LLPipeline::init() mDeferredVB->allocateBuffer(8, 0); { + const LLRenderContract::TonemapFixture fixture = LLRenderContract::makeTonemapFixture(); mScreenTriangleVB = new LLVertexBuffer(LLVertexBuffer::MAP_VERTEX); mScreenTriangleVB->allocateBuffer(3, 0); LLStrider vert; mScreenTriangleVB->getVertexStrider(vert); - vert[0].set(-1, 1, 0); - vert[1].set(-1, -3, 0); - vert[2].set(3, 1, 0); + for (std::size_t vertex = 0; vertex < 3; ++vertex) + { + const std::size_t component = vertex * 4; + vert[vertex].set(fixture.mScreenTriangle[component], fixture.mScreenTriangle[component + 1], + fixture.mScreenTriangle[component + 2]); + } mScreenTriangleVB->unmapBuffer(); } @@ -7234,73 +7251,529 @@ void LLPipeline::generateExposure(LLRenderTarget* src, LLRenderTarget* dst, bool extern LLPointer gEXRImage; -void LLPipeline::tonemap(LLRenderTarget* src, LLRenderTarget* dst, bool gamma_correct) +namespace { - LL_PROFILE_GPU_ZONE("tonemap"); +using LLRenderContract::PixelFormat; +using LLRenderContract::TonemapInputs; +using LLRenderContract::TonemapParameters; +using LLRenderContract::TonemapVariant; + +struct ResolvedTonemap +{ + TonemapVariant mVariant = TonemapVariant::Deferred; + TonemapParameters mParameters; + LLGLSLShader* mShader = nullptr; +}; + +TonemapVariant tonemapVariant(bool no_post, bool gamma_correct, bool legacy_gamma) +{ + if (!gamma_correct) + { + return no_post ? TonemapVariant::NoPost : TonemapVariant::Deferred; + } + if (legacy_gamma) + { + return no_post ? TonemapVariant::NoPostLegacyGammaCorrect : TonemapVariant::LegacyGammaCorrect; + } + return no_post ? TonemapVariant::NoPostGammaCorrect : TonemapVariant::GammaCorrect; +} + +LLGLSLShader* tonemapShader(TonemapVariant variant) +{ + switch (variant) + { + case TonemapVariant::Deferred: + return &gDeferredPostTonemapProgram; + case TonemapVariant::NoPost: + return &gNoPostTonemapProgram; + case TonemapVariant::GammaCorrect: + return &gDeferredPostTonemapGammaCorrectProgram; + case TonemapVariant::NoPostGammaCorrect: + return &gNoPostTonemapGammaCorrectProgram; + case TonemapVariant::LegacyGammaCorrect: + return &gDeferredPostTonemapLegacyGammaCorrectProgram; + case TonemapVariant::NoPostLegacyGammaCorrect: + return &gNoPostTonemapLegacyGammaCorrectProgram; + } + return nullptr; +} + +ResolvedTonemap resolveTonemap(bool gamma_correct) +{ + static LLCachedControl build_no_post(gSavedSettings, "RenderDisablePostProcessing", false); + static LLCachedControl should_auto_adjust(gSavedSettings, "RenderSkyAutoAdjustLegacy", false); + static LLCachedControl exposure(gSavedSettings, "RenderExposure", 1.f); + static LLCachedControl tonemap_type(gSavedSettings, "RenderTonemapType", 0U); + + LLSettingsSky::ptr_t sky = LLEnvironment::instance().getCurrentSky(); + const F32 ambiance = sky->getReflectionProbeAmbiance(should_auto_adjust); + const bool no_post = gSnapshotNoPost || ambiance == 0.f || (build_no_post && gFloaterTools && gFloaterTools->isAvailable()); + const bool legacy_gamma = gamma_correct && ambiance == 0.f; + + ResolvedTonemap result; + result.mVariant = tonemapVariant(no_post, gamma_correct, legacy_gamma); + result.mParameters = { llclamp(static_cast(exposure), 0.5f, 4.f), sky->getTonemapMix(should_auto_adjust), + static_cast(tonemap_type), sky->getGamma() }; + result.mShader = tonemapShader(result.mVariant); + return result; +} + +void submitTonemapLegacy(LLRenderTarget* src, LLRenderTarget* exposure_map, LLRenderTarget* dst, + LLVertexBuffer* screen_triangle, LLGLSLShader* shader, F32 exposure_value, + U32 tonemap_type_value, F32 tonemap_mix_value, std::optional gamma) +{ dst->bindTarget(); - // gamma correct lighting { - static LLCachedControl buildNoPost(gSavedSettings, "RenderDisablePostProcessing", false); + LLGLDepthTest depth(GL_FALSE, GL_FALSE, GL_LEQUAL); + shader->bind(); + shader->bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, src, false, LLTexUnit::TFO_POINT); + shader->bindTexture(LLShaderMgr::EXPOSURE_MAP, exposure_map); + shader->uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, static_cast(src->getWidth()), + static_cast(src->getHeight())); + if (gamma) + { + shader->uniform1f(LLShaderMgr::GAMMA, *gamma); + } - LLGLDepthTest depth(GL_FALSE, GL_FALSE); + static LLStaticHashedString exposure("exposure"); + static LLStaticHashedString tonemap_mix("tonemap_mix"); + static LLStaticHashedString tonemap_type("tonemap_type"); + shader->uniform1f(exposure, exposure_value); + shader->uniform1i(tonemap_type, static_cast(tonemap_type_value)); + shader->uniform1f(tonemap_mix, tonemap_mix_value); - // Apply gamma correction to the frame here. + screen_triangle->setBuffer(); + screen_triangle->drawArrays(LLRender::TRIANGLES, 0, 3); + gGL.getTexUnit(0)->unbind(src->getUsage()); + shader->unbind(); + } + dst->flush(); +} - static LLCachedControl should_auto_adjust(gSavedSettings, "RenderSkyAutoAdjustLegacy", false); +void renderTonemapLegacy(LLRenderTarget* src, LLRenderTarget* exposure_map, LLRenderTarget* dst, + LLVertexBuffer* screen_triangle, bool gamma_correct) +{ + static LLCachedControl build_no_post(gSavedSettings, "RenderDisablePostProcessing", false); + static LLCachedControl should_auto_adjust(gSavedSettings, "RenderSkyAutoAdjustLegacy", false); - LLSettingsSky::ptr_t psky = LLEnvironment::instance().getCurrentSky(); + LLSettingsSky::ptr_t sky = LLEnvironment::instance().getCurrentSky(); + const bool no_post = gSnapshotNoPost || sky->getReflectionProbeAmbiance(should_auto_adjust) == 0.f || + (build_no_post && gFloaterTools && gFloaterTools->isAvailable()); + LLGLSLShader* shader = nullptr; + if (gamma_correct) + { + const bool legacy_gamma = sky->getReflectionProbeAmbiance(should_auto_adjust) == 0.f; + if (legacy_gamma) + { + shader = no_post ? &gNoPostTonemapLegacyGammaCorrectProgram : &gDeferredPostTonemapLegacyGammaCorrectProgram; + } + else + { + shader = no_post ? &gNoPostTonemapGammaCorrectProgram : &gDeferredPostTonemapGammaCorrectProgram; + } + } + else + { + shader = no_post ? &gNoPostTonemapProgram : &gDeferredPostTonemapProgram; + } + + static LLCachedControl exposure_setting(gSavedSettings, "RenderExposure", 1.f); + static LLCachedControl tonemap_type_setting(gSavedSettings, "RenderTonemapType", 0U); + submitTonemapLegacy(src, exposure_map, dst, screen_triangle, shader, + llclamp(static_cast(exposure_setting), 0.5f, 4.f), + static_cast(tonemap_type_setting), sky->getTonemapMix(should_auto_adjust), std::nullopt); +} + +bool poisonTonemapState(LLGLSLShader* shader, TonemapVariant variant, LLRenderTarget* scene, LLRenderTarget* exposure_map) +{ + shader->bind(); + const S32 scene_channel = shader->bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, scene, false, LLTexUnit::TFO_POINT); + const S32 exposure_channel = shader->bindTexture(LLShaderMgr::EXPOSURE_MAP, exposure_map); + shader->uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, 3.f, 5.f); + shader->uniform1f(LLShaderMgr::GAMMA, 0.7f); + + static LLStaticHashedString exposure("exposure"); + static LLStaticHashedString tonemap_mix("tonemap_mix"); + static LLStaticHashedString tonemap_type("tonemap_type"); + shader->uniform1f(exposure, 0.55f); + shader->uniform1f(tonemap_mix, 0.15f); + shader->uniform1i(tonemap_type, 3); + shader->unbind(); + LLVertexBuffer::unbind(); + + const bool no_post = (static_cast(variant) & 1U) != 0; + return scene_channel >= 0 && (no_post || exposure_channel >= 0); +} + +void poisonTonemapSamplerState(LLRenderTarget* scene, LLRenderTarget* exposure_map) +{ + LLTexUnit* unit = gGL.getTexUnit(0); + unit->bindManual(scene->getUsage(), scene->getTexture()); + unit->setTextureFilteringOption(LLTexUnit::TFO_BILINEAR); + unit->setTextureAddressMode(LLTexUnit::TAM_CLAMP); + unit->bindManual(exposure_map->getUsage(), exposure_map->getTexture()); + unit->setTextureFilteringOption(LLTexUnit::TFO_POINT); + unit->setTextureAddressMode(LLTexUnit::TAM_CLAMP); +} - bool no_post = gSnapshotNoPost || psky->getReflectionProbeAmbiance(should_auto_adjust) == 0.f || (buildNoPost && gFloaterTools && gFloaterTools->isAvailable()); - LLGLSLShader* shader = nullptr; - if(gamma_correct) +std::optional contractPixelFormat(U32 format) +{ + switch (format) + { + case GL_RGBA: + case GL_RGBA8: + return PixelFormat::RGBA8Unorm; + case GL_RGBA16F: + return PixelFormat::RGBA16Float; + default: + return std::nullopt; + } +} + +bool uploadTonemapPixels(LLRenderTarget& target, GLenum format, U32 width, U32 height, const GLfloat* pixels) +{ + clear_glerror(); + gGL.getTexUnit(0)->bindManual(target.getUsage(), target.getTexture()); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, static_cast(width), static_cast(height), format, GL_FLOAT, pixels); + return glGetError() == GL_NO_ERROR; +} + +bool uploadTonemapHalfPixels(LLRenderTarget& target, GLenum format, U32 width, U32 height, const std::uint16_t* pixels) +{ + clear_glerror(); + gGL.getTexUnit(0)->bindManual(target.getUsage(), target.getTexture()); + glTexSubImage2D(GL_TEXTURE_2D, 0, 0, 0, static_cast(width), static_cast(height), format, + GL_HALF_FLOAT, pixels); + return glGetError() == GL_NO_ERROR; +} + +bool readTonemapPixels(LLRenderTarget& target, U32 width, U32 height, std::vector& pixels) +{ + clear_glerror(); + target.bindTarget(); + glReadPixels(0, 0, static_cast(width), static_cast(height), GL_RGBA, GL_FLOAT, pixels.data()); + const bool success = glGetError() == GL_NO_ERROR; + target.flush(); + return success; +} + +std::vector canonicalTonemapReadback(PixelFormat format, const std::vector& pixels) +{ + std::vector canonical; + canonical.reserve(pixels.size()); + for (GLfloat value : pixels) + { + if (!std::isfinite(value)) { - bool legacy_gamma = psky->getReflectionProbeAmbiance(should_auto_adjust) == 0.f; - if(legacy_gamma) - { - shader = no_post ? &gNoPostTonemapLegacyGammaCorrectProgram : &gDeferredPostTonemapLegacyGammaCorrectProgram; - } - else - { - shader = no_post ? &gNoPostTonemapGammaCorrectProgram : &gDeferredPostTonemapGammaCorrectProgram; - } + canonical.push_back(value); + } + else if (format == PixelFormat::RGBA8Unorm) + { + const long code = std::lround(llclamp(value, 0.f, 1.f) * 255.f); + canonical.push_back(static_cast(code) / 255.f); } else { - shader = no_post ? &gNoPostTonemapProgram : &gDeferredPostTonemapProgram; + canonical.push_back(LLRenderContract::halfBitsToFloat( + LLRenderContract::floatToHalfBits(value))); } + } + return canonical; +} - shader->bind(); +} - S32 channel = 0; +void LLPipeline::tonemap(LLRenderTarget* src, LLRenderTarget* dst, bool gamma_correct) +{ + LL_PROFILE_GPU_ZONE("tonemap"); - shader->bindTexture(LLShaderMgr::DEFERRED_DIFFUSE, src, false, LLTexUnit::TFO_POINT); + static LLCachedControl use_contract(gSavedSettings, "RenderUseTonemapContract", false); + if (!use_contract) + { + renderTonemapLegacy(src, &mExposureMap, dst, mScreenTriangleVB, gamma_correct); + return; + } - shader->bindTexture(LLShaderMgr::EXPOSURE_MAP, &mExposureMap); + const ResolvedTonemap resolved = resolveTonemap(gamma_correct); + const auto output_format = contractPixelFormat(dst->getColorFormat()); + if (output_format) + { + TonemapInputs inputs; + inputs.mFrame = gFrameCount == 0 ? 1 : static_cast(gFrameCount); + inputs.mSourceExtent = { src->getWidth(), src->getHeight() }; + inputs.mDestinationExtent = { dst->getWidth(), dst->getHeight() }; + inputs.mDestinationFormat = *output_format; + inputs.mVariant = resolved.mVariant; + inputs.mParameters = resolved.mParameters; + + auto frame = LLRenderContract::buildTonemapFrame(inputs); + if (frame) + { + const auto& handles = inputs.mHandles; + LLRenderGLTonemap::Registry registry; + const LLRenderContract::ShaderProgramKey program{ "deferred.tonemap", static_cast(inputs.mVariant) }; + const bool registered = registry.addBuffer(handles.mScreenTriangle, mScreenTriangleVB) && + registry.addImage(handles.mScene, src) && registry.addImage(handles.mExposure, &mExposureMap) && + registry.addImage(handles.mDestination, dst) && + registry.addSampler(handles.mPointSampler, LLRenderGLTonemap::Sampler::Point) && + registry.addSampler(handles.mLinearSampler, LLRenderGLTonemap::Sampler::Linear) && + registry.addPipeline(handles.mPipeline, program, resolved.mShader); + if (registered && LLRenderGLTonemap::execute(*frame, registry)) + { + return; + } + } + } - shader->uniform2f(LLShaderMgr::DEFERRED_SCREEN_RES, (GLfloat)src->getWidth(), (GLfloat)src->getHeight()); + LL_WARNS_ONCE("RenderContract") << "Tonemap packet rejected before execution; using the legacy path" << LL_ENDL; + renderTonemapLegacy(src, &mExposureMap, dst, mScreenTriangleVB, gamma_correct); +} - static LLCachedControl exposure(gSavedSettings, "RenderExposure", 1.f); +bool LLPipeline::runTonemapContractParity() +{ + constexpr F32 TOLERANCE = 0.f; + constexpr U32 WIDTH = LLRenderContract::TONEMAP_DIAGNOSTIC_WIDTH; + constexpr U32 HEIGHT = LLRenderContract::TONEMAP_DIAGNOSTIC_HEIGHT; + struct OutputFormat + { + U32 mGLFormat; + PixelFormat mContractFormat; + const char* mLabel; + }; + constexpr std::array output_formats{ OutputFormat{ GL_RGBA, PixelFormat::RGBA8Unorm, "rgba8" }, + OutputFormat{ GL_RGBA16F, PixelFormat::RGBA16Float, "rgba16f" } }; + + const LLRenderContract::TonemapFixture fixture = LLRenderContract::makeTonemapFixture(); + const LLRenderContract::TonemapCases diagnostic_cases = LLRenderContract::makeTonemapCases(); + LLRenderContract::TonemapArtifact artifact = LLRenderContract::makeTonemapArtifact(); + std::vector poison_pixels(WIDTH * HEIGHT * 4, 0.25f); + const GLfloat poison_exposure_pixel = 0.2f; + + LLRenderTarget scene; + LLRenderTarget exposure_map; + LLRenderTarget poison_scene; + LLRenderTarget poison_exposure_map; + if (!scene.allocate(WIDTH, HEIGHT, GL_RGBA16F) || !exposure_map.allocate(1, 1, GL_R16F) || + !poison_scene.allocate(WIDTH, HEIGHT, GL_RGBA16F) || !poison_exposure_map.allocate(1, 1, GL_R16F) || + !uploadTonemapHalfPixels(scene, GL_RGBA, WIDTH, HEIGHT, fixture.mSceneRGBA16F.data()) || + !uploadTonemapHalfPixels(exposure_map, GL_RED, 1, 1, &fixture.mExposureR16F) || + !uploadTonemapPixels(poison_scene, GL_RGBA, WIDTH, HEIGHT, poison_pixels.data()) || + !uploadTonemapPixels(poison_exposure_map, GL_RED, 1, 1, &poison_exposure_pixel)) + { + std::cout << "TONEMAP_CONTRACT_PARITY result=fail reason=fixture_setup" << std::endl; + return false; + } - F32 e = llclamp(exposure(), 0.5f, 4.f); + LLGLDisable blend(GL_BLEND); + LLGLDisable cull(GL_CULL_FACE); + LLGLDisable scissor(GL_SCISSOR_TEST); + gGL.setColorMask(true, true); - static LLStaticHashedString s_exposure("exposure"); - static LLStaticHashedString tonemap_mix("tonemap_mix"); - static LLStaticHashedString tonemap_type("tonemap_type"); + U32 case_count = 0; + U64 mismatches = 0; + U32 execution_failures = 0; + F32 worst_delta = 0.f; + std::string first_failure; + std::vector legacy_pixels(WIDTH * HEIGHT * 4); + std::vector contract_pixels(WIDTH * HEIGHT * 4); + std::vector legacy_sentinel(WIDTH * HEIGHT * 4); + std::vector contract_sentinel(WIDTH * HEIGHT * 4); - shader->uniform1f(s_exposure, e); + for (const OutputFormat& output_format : output_formats) + { + LLRenderTarget legacy_output; + LLRenderTarget contract_output; + if (!legacy_output.allocate(WIDTH, HEIGHT, output_format.mGLFormat) || + !contract_output.allocate(WIDTH, HEIGHT, output_format.mGLFormat)) + { + std::cout << "TONEMAP_CONTRACT_PARITY result=fail reason=output_allocation format=" << output_format.mLabel << std::endl; + return false; + } - static LLCachedControl tonemap_type_setting(gSavedSettings, "RenderTonemapType", 0U); - shader->uniform1i(tonemap_type, tonemap_type_setting); - shader->uniform1f(tonemap_mix, psky->getTonemapMix(should_auto_adjust())); + for (const LLRenderContract::TonemapCase& diagnostic_case : diagnostic_cases) + { + if (diagnostic_case.mInputs.mDestinationFormat != output_format.mContractFormat) + { + continue; + } - mScreenTriangleVB->setBuffer(); - mScreenTriangleVB->drawArrays(LLRender::TRIANGLES, 0, 3); + ++case_count; + const TonemapInputs& inputs = diagnostic_case.mInputs; + const TonemapParameters& parameters = inputs.mParameters; + LLGLSLShader* shader = tonemapShader(inputs.mVariant); + const F32 legacy_sentinel_value = 0.04f * static_cast(1 + case_count % 7); + const F32 contract_sentinel_value = 0.96f - 0.04f * static_cast(1 + case_count % 7); + std::fill(legacy_sentinel.begin(), legacy_sentinel.end(), legacy_sentinel_value); + std::fill(contract_sentinel.begin(), contract_sentinel.end(), contract_sentinel_value); + const bool outputs_seeded = uploadTonemapPixels(legacy_output, GL_RGBA, WIDTH, HEIGHT, legacy_sentinel.data()) && + uploadTonemapPixels(contract_output, GL_RGBA, WIDTH, HEIGHT, contract_sentinel.data()); + if (!poisonTonemapState(shader, inputs.mVariant, &poison_scene, &poison_exposure_map)) + { + ++execution_failures; + if (first_failure.empty()) + { + std::ostringstream failure; + failure << "format=" << output_format.mLabel << " variant=" << static_cast(inputs.mVariant) + << " tonemap_type=" << inputs.mParameters.mTonemapType << " reason=legacy_state_poison"; + first_failure = failure.str(); + } + continue; + } + clear_glerror(); + submitTonemapLegacy(&scene, &exposure_map, &legacy_output, mScreenTriangleVB, shader, + parameters.mExposure, parameters.mTonemapType, parameters.mTonemapMix, + parameters.mGamma); + const bool legacy_gl_ok = glGetError() == GL_NO_ERROR; + + LLRenderGLTonemap::Registry registry; + const auto& handles = inputs.mHandles; + const LLRenderContract::ShaderProgramKey program{ "deferred.tonemap", static_cast(inputs.mVariant) }; + const bool registered = registry.addBuffer(handles.mScreenTriangle, mScreenTriangleVB) && + registry.addImage(handles.mScene, &scene) && registry.addImage(handles.mExposure, &exposure_map) && + registry.addImage(handles.mDestination, &contract_output) && + registry.addSampler(handles.mPointSampler, LLRenderGLTonemap::Sampler::Point) && + registry.addSampler(handles.mLinearSampler, LLRenderGLTonemap::Sampler::Linear) && + registry.addPipeline(handles.mPipeline, program, shader); + poisonTonemapSamplerState(&scene, &exposure_map); + const bool poisoned = poisonTonemapState(shader, inputs.mVariant, &poison_scene, &poison_exposure_map); + bool executed = false; + if (registered && poisoned) + { + LLGLEnable wrong_blend(GL_BLEND); + LLGLEnable wrong_cull(GL_CULL_FACE); + LLGLEnable wrong_scissor(GL_SCISSOR_TEST); + gGL.setColorMask(false, false); + glScissor(0, 0, 1, 1); + clear_glerror(); + executed = LLRenderGLTonemap::execute(diagnostic_case.mFrame, registry); + executed = executed && glGetError() == GL_NO_ERROR; + gGL.setColorMask(true, true); + } + const bool readback = outputs_seeded && legacy_gl_ok && executed && + readTonemapPixels(legacy_output, WIDTH, HEIGHT, legacy_pixels) && + readTonemapPixels(contract_output, WIDTH, HEIGHT, contract_pixels); + if (!readback) + { + ++execution_failures; + if (first_failure.empty()) + { + std::ostringstream failure; + failure << "format=" << output_format.mLabel << " variant=" << static_cast(inputs.mVariant) + << " tonemap_type=" << inputs.mParameters.mTonemapType << " reason=execution_or_readback"; + first_failure = failure.str(); + } + continue; + } - gGL.getTexUnit(channel)->unbind(src->getUsage()); - shader->unbind(); + artifact.mCases[diagnostic_case.mKey.mIndex - 1].mPixels = + canonicalTonemapReadback(output_format.mContractFormat, legacy_pixels); + for (U32 component = 0; component < legacy_pixels.size(); ++component) + { + const F32 delta = std::abs(legacy_pixels[component] - contract_pixels[component]); + const bool finite = std::isfinite(legacy_pixels[component]) && std::isfinite(contract_pixels[component]); + if (finite) + { + worst_delta = llmax(worst_delta, delta); + } + if (!finite || delta > TOLERANCE) + { + ++mismatches; + if (first_failure.empty()) + { + const U32 pixel = component / 4; + std::ostringstream failure; + failure << "format=" << output_format.mLabel << " variant=" << static_cast(inputs.mVariant) + << " tonemap_type=" << inputs.mParameters.mTonemapType << " x=" << pixel % WIDTH + << " y=" << pixel / WIDTH << " channel=" << component % 4 + << " legacy=" << legacy_pixels[component] << " contract=" << contract_pixels[component] + << " delta=" << delta; + first_failure = failure.str(); + } + } + } + } } - dst->flush(); + + U32 rejection_failures = 0; + LLRenderTarget rejection_output; + std::vector sentinel_pixels(WIDTH * HEIGHT * 4); + for (U32 component = 0; component < sentinel_pixels.size(); ++component) + { + sentinel_pixels[component] = component % 3 == 0 ? 1.f : 0.f; + } + std::vector rejected_pixels(WIDTH * HEIGHT * 4); + const LLRenderContract::TonemapCase& rejected_case = diagnostic_cases.front(); + const TonemapInputs& rejected_inputs = rejected_case.mInputs; + LLRenderGLTonemap::Registry rejected_registry; + const auto& rejected_handles = rejected_inputs.mHandles; + const LLRenderContract::ShaderProgramKey rejected_program{ "deferred.tonemap", 0 }; + const bool rejection_registered = + rejected_registry.addBuffer(rejected_handles.mScreenTriangle, mScreenTriangleVB) && + rejected_registry.addImage({ rejected_handles.mScene.mIndex, rejected_handles.mScene.mGeneration + 1 }, &scene) && + rejected_registry.addImage(rejected_handles.mExposure, &exposure_map) && + rejected_registry.addImage(rejected_handles.mDestination, &rejection_output) && + rejected_registry.addSampler(rejected_handles.mPointSampler, LLRenderGLTonemap::Sampler::Point) && + rejected_registry.addSampler(rejected_handles.mLinearSampler, LLRenderGLTonemap::Sampler::Linear) && + rejected_registry.addPipeline(rejected_handles.mPipeline, rejected_program, tonemapShader(TonemapVariant::Deferred)); + const bool rejection_setup = rejection_output.allocate(WIDTH, HEIGHT, GL_RGBA) && + uploadTonemapPixels(rejection_output, GL_RGBA, WIDTH, HEIGHT, sentinel_pixels.data()); + const bool rejected = rejection_setup && rejection_registered && + !LLRenderGLTonemap::execute(rejected_case.mFrame, rejected_registry) && + readTonemapPixels(rejection_output, WIDTH, HEIGHT, rejected_pixels) && rejected_pixels == sentinel_pixels; + if (!rejected) + { + ++rejection_failures; + if (first_failure.empty()) + { + first_failure = "reason=stale_generation_rejection_mutated_destination"; + } + } + + bool success = mismatches == 0 && execution_failures == 0 && rejection_failures == 0; + const std::string artifact_path = gSavedSettings.getString("RenderTonemapArtifactPath"); + bool artifact_written = false; + if (success && !artifact_path.empty()) + { + std::string artifact_error; + if (!LLRenderContract::validateTonemapArtifact(artifact, &artifact_error)) + { + success = false; + first_failure = "reason=artifact_validation detail=" + artifact_error; + } + else + { + artifact_written = LLRenderContract::writeTonemapArtifact(artifact_path, artifact, &artifact_error); + if (!artifact_written) + { + success = false; + first_failure = "reason=artifact_write detail=" + artifact_error; + } + } + } + std::ostringstream result; + result << std::setprecision(9) << "TONEMAP_CONTRACT_PARITY result=" << (success ? "pass" : "fail") + << " cases=" << case_count << " tolerance=" << TOLERANCE << " max_abs_error=" << worst_delta + << " mismatches=" << mismatches << " execution_failures=" << execution_failures + << " rejection_failures=" << rejection_failures + << " artifact=" << (artifact_path.empty() ? "disabled" : artifact_written ? "written" : "failed"); + if (!first_failure.empty()) + { + result << " first_failure={" << first_failure << "}"; + } + std::cout << result.str() << std::endl; + LL_INFOS("RenderContractParity") << result.str() << LL_ENDL; + return success; +} + +bool LLPipeline::runMaterialContractParity() +{ + return LLMaterialParity::run(); +} + +bool LLPipeline::runTextureUploadContractParity() +{ + return LLTextureUploadParity::run(); } void LLPipeline::gammaCorrect(LLRenderTarget* src, LLRenderTarget* dst) diff --git a/indra/newview/pipeline.h b/indra/newview/pipeline.h index c051306385e..fcbb0d0bbfc 100644 --- a/indra/newview/pipeline.h +++ b/indra/newview/pipeline.h @@ -156,6 +156,9 @@ class LLPipeline void generateLuminance(LLRenderTarget* src, LLRenderTarget* dst); void generateExposure(LLRenderTarget* src, LLRenderTarget* dst, bool use_history = true); void tonemap(LLRenderTarget* src, LLRenderTarget* dst, bool gamma_correct); + bool runTonemapContractParity(); + bool runMaterialContractParity(); + bool runTextureUploadContractParity(); void gammaCorrect(LLRenderTarget* src, LLRenderTarget* dst); void generateGlow(LLRenderTarget* src); void applyCAS(LLRenderTarget* src, LLRenderTarget* dst); diff --git a/indra/newview/tests/llbenchmarkappearance_test.cpp b/indra/newview/tests/llbenchmarkappearance_test.cpp new file mode 100644 index 00000000000..d9d07a13f5b --- /dev/null +++ b/indra/newview/tests/llbenchmarkappearance_test.cpp @@ -0,0 +1,108 @@ +/** + * @file llbenchmarkappearance_test.cpp + * @brief Tests for renderer benchmark appearance classification. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" +#include "../test/lltut.h" + +#include "../llbenchmarkappearance.h" + +namespace +{ +LLBenchmarkAppearance::Facts readyFacts() +{ + LLBenchmarkAppearance::Facts facts; + facts.avatar_valid = true; + facts.cof_present = true; + facts.cof_complete = true; + facts.required_links_resolved.fill(true); + facts.required_wearables_delivered.fill(true); + facts.avatar_loaded = true; + return facts; +} +} + +namespace tut +{ +struct benchmark_appearance_test {}; +typedef test_group benchmark_appearance_test_t; +typedef benchmark_appearance_test_t::object benchmark_appearance_test_object_t; +benchmark_appearance_test_t benchmark_appearance_tests("LLBenchmarkAppearance"); + +template<> template<> +void benchmark_appearance_test_object_t::test<1>() +{ + ensure_equals("ready", LLBenchmarkAppearance::classify(readyFacts()), "ready"); +} + +template<> template<> +void benchmark_appearance_test_object_t::test<2>() +{ + auto facts = readyFacts(); + facts.avatar_valid = false; + facts.cof_complete = false; + facts.required_links_resolved[0] = false; + ensure_equals( + "avatar validity has precedence", + LLBenchmarkAppearance::classify(facts), + "avatar-unavailable"); +} + +template<> template<> +void benchmark_appearance_test_object_t::test<3>() +{ + auto missing = readyFacts(); + missing.cof_present = false; + ensure_equals("missing COF", LLBenchmarkAppearance::classify(missing), "cof-incomplete"); + + auto incomplete = readyFacts(); + incomplete.cof_complete = false; + ensure_equals("incomplete COF", LLBenchmarkAppearance::classify(incomplete), "cof-incomplete"); +} + +template<> template<> +void benchmark_appearance_test_object_t::test<4>() +{ + auto facts = readyFacts(); + facts.required_links_resolved[1] = false; + facts.required_wearables_delivered[1] = false; + ensure_equals( + "required links precede delivery", + LLBenchmarkAppearance::classify(facts), + "required-link-missing-or-unresolved"); +} + +template<> template<> +void benchmark_appearance_test_object_t::test<5>() +{ + auto facts = readyFacts(); + facts.required_wearables_delivered[2] = false; + facts.avatar_loaded = false; + ensure_equals( + "wearable delivery precedes later blockers", + LLBenchmarkAppearance::classify(facts), + "wearable-delivery-pending-or-failed"); +} + +template<> template<> +void benchmark_appearance_test_object_t::test<6>() +{ + auto facts = readyFacts(); + facts.avatar_loaded = false; + ensure_equals( + "later avatar blocker", + LLBenchmarkAppearance::classify(facts), + "avatar-later-blocker"); +} +} diff --git a/indra/newview/tests/llbenchmarkdisplay_test.cpp b/indra/newview/tests/llbenchmarkdisplay_test.cpp new file mode 100644 index 00000000000..75e827f9006 --- /dev/null +++ b/indra/newview/tests/llbenchmarkdisplay_test.cpp @@ -0,0 +1,41 @@ +/** + * @file llbenchmarkdisplay_test.cpp + * @brief Tests for renderer benchmark display-scale normalization. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" +#include "../test/lltut.h" + +#include "../llbenchmarkdisplay.h" + +namespace tut +{ +struct benchmark_display_test {}; +typedef test_group benchmark_display_test_t; +typedef benchmark_display_test_t::object benchmark_display_test_object_t; +benchmark_display_test_t benchmark_display_tests("LLBenchmarkDisplay"); + +template<> template<> +void benchmark_display_test_object_t::test<1>() +{ + ensure_equals("1x display", LLBenchmarkDisplay::configuredUIScale(1.25f, 1.f, 1.f), 1.f); + ensure_equals("2x display", LLBenchmarkDisplay::configuredUIScale(1.25f, 1.f, 2.f), 0.5f); +} + +template<> template<> +void benchmark_display_test_object_t::test<2>() +{ + ensure_equals("ordinary launch", LLBenchmarkDisplay::configuredUIScale(1.25f, 0.f, 2.f), 1.25f); + ensure_equals("invalid backing scale", LLBenchmarkDisplay::configuredUIScale(1.25f, 1.f, 0.f), 1.25f); +} +} diff --git a/indra/newview/tests/llcachemigration_test.cpp b/indra/newview/tests/llcachemigration_test.cpp new file mode 100644 index 00000000000..aed62d2d0ce --- /dev/null +++ b/indra/newview/tests/llcachemigration_test.cpp @@ -0,0 +1,45 @@ +/** + * @file llcachemigration_test.cpp + * @brief Tests for cache-directory migration guards. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" +#include "../test/lltut.h" + +#include "../llcachemigration.h" + +namespace tut +{ +struct cache_migration_test {}; +typedef test_group cache_migration_test_t; +typedef cache_migration_test_t::object cache_migration_test_object_t; +cache_migration_test_t cache_migration_tests("LLCacheMigration"); + +template<> template<> +void cache_migration_test_object_t::test<1>() +{ + ensure("identical cache is not migrated", + !LLCacheMigration::required("/profile/cache", "/profile/cache")); + ensure("normalized identical cache is not migrated", + !LLCacheMigration::required("/profile/./cache", "/profile/cache")); + ensure("UTF-8 normalized identical cache is not migrated", + !LLCacheMigration::required("/profile/\xC3\xA9/./cache", "/profile/\xC3\xA9/cache")); +} + +template<> template<> +void cache_migration_test_object_t::test<2>() +{ + ensure("different cache is migrated", + LLCacheMigration::required("/profile/cache", "/system/cache")); +} +} diff --git a/indra/newview/tests/llcontractparityargs_test.cpp b/indra/newview/tests/llcontractparityargs_test.cpp new file mode 100644 index 00000000000..e7532cf4b84 --- /dev/null +++ b/indra/newview/tests/llcontractparityargs_test.cpp @@ -0,0 +1,104 @@ +/** + * @file llcontractparityargs_test.cpp + * @brief Tests for early renderer parity command-line selection. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the License only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" +#include "../test/lltut.h" + +#include "../llcontractparityargs.h" + +#include +#include +#include + +namespace tut +{ +struct contract_parity_args_test +{ + static LLContractParitySelection parse(std::initializer_list arguments) + { + std::vector storage(arguments.begin(), arguments.end()); + std::vector argv; + argv.reserve(storage.size()); + for (std::string& argument : storage) + { + argv.push_back(argument.data()); + } + return getRawContractParitySelection(static_cast(argv.size()), argv.data()); + } +}; + +typedef test_group contract_parity_args_test_t; +typedef contract_parity_args_test_t::object contract_parity_args_test_object_t; +contract_parity_args_test_t contract_parity_args_tests("LLContractParityArgs"); + +template<> +template<> +void contract_parity_args_test_object_t::test<1>() +{ + const auto ordinary = parse({ "viewer", "--materialartifact", "RenderMaterialContractParityTest" }); + ensure("artifact value is not a material selection", !ordinary.mMaterial); + ensure("ordinary launch is not a tonemap selection", !ordinary.mTonemap); + ensure("ordinary launch is not a texture upload selection", !ordinary.mTextureUpload); +} + +template<> +template<> +void contract_parity_args_test_object_t::test<2>() +{ + ensure("long material flag", parse({ "viewer", "--materialparity" }).mMaterial); + ensure("short material flag", parse({ "viewer", "-materialparity" }).mMaterial); + ensure("long tonemap flag", parse({ "viewer", "--tonemapparity" }).mTonemap); + ensure("short tonemap flag", parse({ "viewer", "-tonemapparity" }).mTonemap); + ensure("long texture upload flag", parse({ "viewer", "--textureuploadparity" }).mTextureUpload); + ensure("short texture upload flag", parse({ "viewer", "-textureuploadparity" }).mTextureUpload); +} + +template<> +template<> +void contract_parity_args_test_object_t::test<3>() +{ + ensure("separate set pair", parse({ "viewer", "--set", "RenderMaterialContractParityTest", "true" }).mMaterial); + ensure("qualified set pair", parse({ "viewer", "-set", "Global.RenderMaterialContractParityTest", "1" }).mMaterial); + ensure("long attached set name", parse({ "viewer", "--set=RenderTonemapContractParityTest", "T" }).mTonemap); + ensure("short attached set name", parse({ "viewer", "-set=Global.RenderTonemapContractParityTest", " True " }).mTonemap); + ensure("texture upload set pair", + parse({ "viewer", "--set", "RenderTextureUploadContractParityTest", "true" }).mTextureUpload); +} + +template<> +template<> +void contract_parity_args_test_object_t::test<4>() +{ + const auto overridden = parse({ "viewer", "--set", "RenderMaterialContractParityTest", "true", "--materialparity", + "--set=RenderMaterialContractParityTest", "false" }); + ensure("set false overrides direct flag", !overridden.mMaterial); + + const auto repeated = + parse({ "viewer", "--set", "RenderTonemapContractParityTest", "false", "--set", "Global.RenderTonemapContractParityTest", "true" }); + ensure("last composing set pair wins", repeated.mTonemap); +} + +template<> +template<> +void contract_parity_args_test_object_t::test<5>() +{ + ensure("invalid boolean becomes false", + !parse({ "viewer", "--tonemapparity", "--set", "RenderTonemapContractParityTest", "yes" }).mTonemap); + ensure("another control group is ignored", !parse({ "viewer", "--set", "Session.RenderMaterialContractParityTest", "true" }).mMaterial); + ensure("tokens after option terminator are ignored", !parse({ "viewer", "--", "--materialparity" }).mMaterial); + ensure("texture upload tokens after option terminator are ignored", + !parse({ "viewer", "--", "--textureuploadparity" }).mTextureUpload); +} +} // namespace tut diff --git a/indra/newview/tests/lldrawinfotranslator_test.cpp b/indra/newview/tests/lldrawinfotranslator_test.cpp new file mode 100644 index 00000000000..c20e5e1d6c4 --- /dev/null +++ b/indra/newview/tests/lldrawinfotranslator_test.cpp @@ -0,0 +1,562 @@ +/** + * @file lldrawinfotranslator_test.cpp + * @brief Tests for the immutable LLDrawInfo translation boundary. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "lldrawinfotranslator.h" + +#include "lldrawpool.h" +#include "llmaterial.h" +#include "llspatialpartition.h" +#include "lltut.h" + +#include +#include +#include +#include + +namespace +{ +using namespace LLDrawInfoAdapter; +using namespace LLRenderContract; + +constexpr U32 REQUIRED_VERTEX_MASK = LLVertexBuffer::MAP_VERTEX | LLVertexBuffer::MAP_NORMAL | LLVertexBuffer::MAP_TEXCOORD0 | + LLVertexBuffer::MAP_TEXCOORD1 | LLVertexBuffer::MAP_TEXCOORD2 | LLVertexBuffer::MAP_COLOR | + LLVertexBuffer::MAP_TANGENT; + +constexpr std::size_t roleIndex(TextureRole role) +{ + return static_cast(role); +} + +std::uint64_t vertexBufferSize(U32 mask, U32 count) +{ + std::array offsets{}; + return LLVertexBuffer::calcOffsets(mask, offsets.data(), count); +} + +class TestResolver final : public Resolver +{ +public: + std::optional resolveGeometry(const LLVertexBuffer& buffer) const override + { + if (!mResolveGeometry || !mGeometryLive || mGeometrySource != &buffer || mGeometry.mVertexBuffer != mCurrentVertexBuffer || + mGeometry.mIndexBuffer != mCurrentIndexBuffer) + { + return std::nullopt; + } + return mGeometry; + } + + std::optional resolveImage(const LLViewerTexture& texture, TextureRole role) const override + { + const std::size_t index = roleIndex(role); + if (!mResolveImages[index] || !mImagesLive[index] || mImageSources[index] != &texture || + mImages[index].mImage != mCurrentImages[index] || mImages[index].mSampler != mCurrentSamplers[index]) + { + return std::nullopt; + } + return mImages[index]; + } + + std::optional resolvePipeline(const LegacyNormSpecPipelineKey& key) const override + { + if (!mResolvePipeline || !mPipelineLive || key != mExpectedPipelineKey || mPipeline != mCurrentPipeline) + { + return std::nullopt; + } + return mPipeline; + } + + const LLVertexBuffer* mGeometrySource = nullptr; + std::array mImageSources{}; + ResolvedGeometry mGeometry; + std::array mImages{}; + BufferHandle mCurrentVertexBuffer{ 1, 7 }; + BufferHandle mCurrentIndexBuffer{ 2, 9 }; + std::array mCurrentImages{ ImageHandle{ 3, 4 }, ImageHandle{ 4, 5 }, ImageHandle{ 8, 6 } }; + std::array mCurrentSamplers{ SamplerHandle{ 5, 2 }, SamplerHandle{ 7, 3 }, SamplerHandle{ 9, 4 } }; + LegacyNormSpecPipelineKey mExpectedPipelineKey; + PipelineHandle mPipeline{ 6, 3 }; + PipelineHandle mCurrentPipeline{ 6, 3 }; + bool mResolveGeometry = true; + bool mGeometryLive = true; + std::array mResolveImages{ true, true, true }; + std::array mImagesLive{ true, true, true }; + bool mResolvePipeline = true; + bool mPipelineLive = true; +}; + +template +class ScopedNonOwningPointer final : private LLPointer +{ +public: + ScopedNonOwningPointer(LLPointer& target, std::uintptr_t address) : mTarget(target) + { + llassert(mTarget.isNull()); + this->mPointer = reinterpret_cast(address); + LLPointer::swap(mTarget, *this); + } + + ~ScopedNonOwningPointer() + { + LLPointer::swap(mTarget, *this); + this->mPointer = nullptr; + } + + ScopedNonOwningPointer(const ScopedNonOwningPointer&) = delete; + ScopedNonOwningPointer& operator=(const ScopedNonOwningPointer&) = delete; + +private: + LLPointer& mTarget; +}; + +struct DrawInfoFixture +{ + DrawInfoFixture() + { + gDebugGL = false; + + mBuffer = new LLVertexBuffer(REQUIRED_VERTEX_MASK); + mDiffuse = new LLViewerTexture(false); + mNormal = new LLViewerTexture(false); + mSpecular = new LLViewerTexture(false); + mMaterial = new LLMaterial(); + mDraw = new LLDrawInfo(11, 14, 6, 18, mDiffuse, mBuffer, true); + + mDraw->mNormalMap = mNormal; + mDraw->mSpecularMap = mSpecular; + mDraw->mMaterial = mMaterial; + mDraw->mModelMatrix = &mModelMatrix; + mDraw->mTextureMatrix = &mTextureMatrix; + mDraw->mSpecColor = LLVector4(0.2f, 0.4f, 0.8f, 0.6f); + mDraw->mEnvIntensity = 0.625f; + mDraw->mAlphaMaskCutoff = 0.375f; + + mModelMatrix.mMatrix[3][0] = 4.25f; + mTextureMatrix.mMatrix[0][0] = 0.75f; + mTextureMatrix.mMatrix[3][1] = 0.125f; + + mContext.mFrame = 91; + mContext.mPass = { 73 }; + mContext.mPipelineKey = legacyNormSpecModernHDRPipelineKey(); + mContext.mRenderDomain = RenderDomain::World; + mContext.mSubmission = SubmissionKind::DeferredMaterial; + + mResolver.mGeometrySource = mBuffer; + mResolver.mGeometry = { + BufferHandle{ 1, 7 }, BufferHandle{ 2, 9 }, vertexBufferSize(REQUIRED_VERTEX_MASK, 65), sizeof(std::uint16_t) * 65, 65, 65, + IndexType::UInt16 + }; + mResolver.mImageSources = { mDiffuse, mNormal, mSpecular }; + mResolver.mImages = { DrawTextureInput{ ImageHandle{ 3, 4 }, SamplerHandle{ 5, 2 }, { 0, 3, 0, 1 } }, + DrawTextureInput{ ImageHandle{ 4, 5 }, SamplerHandle{ 7, 3 }, { 0, 2, 0, 1 } }, + DrawTextureInput{ ImageHandle{ 8, 6 }, SamplerHandle{ 9, 4 }, { 1, 2, 0, 1 } } }; + mResolver.mExpectedPipelineKey = mContext.mPipelineKey; + } + + std::optional translate() const + { + return translateNonRiggedNormSpecDraw(*mDraw, LLRenderPass::PASS_NORMSPEC, mContext, mResolver); + } + + LLPointer mBuffer; + LLPointer mDiffuse; + LLPointer mNormal; + LLPointer mSpecular; + LLPointer mMaterial; + LLPointer mDraw; + LLMatrix4 mModelMatrix; + LLMatrix4 mTextureMatrix; + Context mContext; + TestResolver mResolver; +}; + +} // namespace + +namespace tut +{ + +struct draw_info_translator_test +{ +}; + +using draw_info_translator_test_group = test_group; +using draw_info_translator_test_object = draw_info_translator_test_group::object; +draw_info_translator_test_group draw_info_translator_tests("draw info translator"); + +template<> +template<> +void draw_info_translator_test_object::test<1>() +{ + DrawInfoFixture fixture; + + const auto packet = fixture.translate(); + ensure("canonical legacy draw translates", packet.has_value()); + ensure("translated packet validates", validLegacyNormSpecDrawPacket(*packet)); + ensure("frame and pass map exactly", packet->mFrame == 91 && packet->mPass == PassId{ 73 }); + ensure("indexed ranges retain element units", + packet->mFirstIndex == 18 && packet->mIndexCount == 6 && packet->mMinVertex == 11 && packet->mMaxVertex == 14); + ensure("geometry handles map exactly", + packet->mHandles.mVertexBuffer == BufferHandle{ 1, 7 } && packet->mHandles.mIndexBuffer == BufferHandle{ 2, 9 }); + ensure("texture records map by role", + packet->mDescriptors.mDiffuse == fixture.mResolver.mImages[roleIndex(TextureRole::Diffuse)] && + packet->mDescriptors.mNormal == fixture.mResolver.mImages[roleIndex(TextureRole::Normal)] && + packet->mDescriptors.mSpecular == fixture.mResolver.mImages[roleIndex(TextureRole::Specular)]); + ensure("model and texture matrices map in storage order", + packet->mModelMatrix[12] == 4.25f && packet->mDiffuseTextureMatrix[0] == 0.75f && packet->mDiffuseTextureMatrix[13] == 0.125f); + ensure("shader constants map exactly", + packet->mSpecularRGBA == std::array{ 0.2f, 0.4f, 0.8f, 0.6f } && packet->mEnvironmentIntensity == 0.625f && + packet->mAlphaCutoff == 0.375f && packet->mEmissiveBrightness == 1.f); + ensure("the complete logical pipeline key is the modern production profile", + packet->mPipelineKey == legacyNormSpecModernHDRPipelineKey()); + + const auto repeated = fixture.translate(); + ensure("translation is deterministic", repeated.has_value() && *repeated == *packet); + + fixture.mResolver.mGeometry.mIndexType = IndexType::UInt32; + fixture.mResolver.mGeometry.mIndexBufferSize = sizeof(std::uint32_t) * fixture.mResolver.mGeometry.mIndexCount; + const auto uint32_packet = fixture.translate(); + ensure("UInt32 index geometry translates with four-byte storage", + uint32_packet.has_value() && uint32_packet->mIndexType == IndexType::UInt32); +} + +template<> +template<> +void draw_info_translator_test_object::test<2>() +{ + DrawInfoFixture fixture; + fixture.mDraw->mModelMatrix = nullptr; + fixture.mDraw->mTextureMatrix = nullptr; + + const auto packet = fixture.translate(); + ensure("draw without optional matrices translates", packet.has_value()); + ensure("null model matrix becomes identity", packet->mModelMatrix == DRAW_IDENTITY_MATRIX4); + ensure("null texture matrix becomes identity", packet->mDiffuseTextureMatrix == DRAW_IDENTITY_MATRIX4); +} + +template<> +template<> +void draw_info_translator_test_object::test<3>() +{ + DrawInfoFixture fixture; + const auto packet = fixture.translate(); + ensure("baseline packet translates", packet.has_value()); + const LegacyNormSpecDrawPacket owned = *packet; + + fixture.mResolver.mGeometry.mVertexBuffer.mGeneration = 17; + fixture.mResolver.mGeometry.mIndexBuffer.mGeneration = 19; + for (DrawTextureInput& image : fixture.mResolver.mImages) + { + image.mImage.mGeneration += 10; + image.mSampler.mGeneration += 10; + } + fixture.mResolver.mPipeline.mGeneration = 23; + ensure("resolver generation changes cannot change the captured packet", *packet == owned); + ensure("packet remains valid after resolver generation changes", validLegacyNormSpecDrawPacket(*packet)); + + fixture.mDraw->mStart = 1; + fixture.mDraw->mEnd = 2; + fixture.mDraw->mCount = 3; + fixture.mDraw->mOffset = 4; + fixture.mDraw->mSpecColor = LLVector4(0.f, 0.f, 0.f, 0.f); + fixture.mDraw->mEnvIntensity = 0.f; + fixture.mDraw->mAlphaMaskCutoff = 1.f; + fixture.mDraw->mFullbright = false; + fixture.mModelMatrix.mMatrix[3][0] = -19.f; + fixture.mTextureMatrix.mMatrix[3][1] = -23.f; + + fixture.mDraw = nullptr; + fixture.mMaterial = nullptr; + fixture.mDiffuse = nullptr; + fixture.mNormal = nullptr; + fixture.mSpecular = nullptr; + fixture.mBuffer = nullptr; + + ensure("source mutation and release cannot change the packet", *packet == owned); + ensure("released-source packet remains valid", validLegacyNormSpecDrawPacket(*packet)); +} + +template<> +template<> +void draw_info_translator_test_object::test<4>() +{ + DrawInfoFixture fixture; + + ensure("only PASS_NORMSPEC is accepted", + !translateNonRiggedNormSpecDraw(*fixture.mDraw, LLRenderPass::PASS_NORMSPEC_RIGGED, fixture.mContext, fixture.mResolver)); + + fixture.mContext.mSubmission = SubmissionKind::Invalid; + ensure("the deferred-material submission is required", !fixture.translate()); + fixture.mContext.mSubmission = SubmissionKind::DeferredMaterial; + + for (RenderDomain domain : + { RenderDomain::Invalid, RenderDomain::HUD, RenderDomain::Impostor, RenderDomain::Reflection, RenderDomain::Cube }) + { + fixture.mContext.mRenderDomain = domain; + ensure("only ordinary world rendering is accepted", !fixture.translate()); + } + fixture.mContext.mRenderDomain = RenderDomain::World; + + fixture.mContext.mFrame = 0; + ensure("a zero frame identity is rejected", !fixture.translate()); + fixture.mContext.mFrame = 91; + + fixture.mContext.mPass = {}; + ensure("a zero pass identity is rejected", !fixture.translate()); + fixture.mContext.mPass = { 73 }; + + fixture.mContext.mPipelineKey.mCullMode = CullMode::Disabled; + ensure("a non-canonical logical pipeline key is rejected", !fixture.translate()); + fixture.mContext.mPipelineKey = legacyNormSpecModernHDRPipelineKey(); + + fixture.mContext.mPipelineKey = legacyNormSpecCompatibilityPipelineKey(); + fixture.mResolver.mExpectedPipelineKey = fixture.mContext.mPipelineKey; + ensure("the compatibility production target profile is accepted", fixture.translate().has_value()); + + fixture.mContext.mPipelineKey = legacyNormSpecDiagnosticPipelineKey(); + fixture.mResolver.mExpectedPipelineKey = fixture.mContext.mPipelineKey; + ensure("the three-target diagnostic profile cannot masquerade as production", !fixture.translate()); + + fixture.mContext.mPipelineKey = legacyNormSpecModernHDRPipelineKey(); + fixture.mResolver.mExpectedPipelineKey = fixture.mContext.mPipelineKey; + + LLPointer material = fixture.mDraw->mMaterial; + fixture.mDraw->mMaterial = nullptr; + ensure("the legacy material marker is required", !fixture.translate()); + fixture.mDraw->mMaterial = material; + + LLPointer diffuse = fixture.mDraw->mTexture; + fixture.mDraw->mTexture = nullptr; + ensure("the diffuse texture is required", !fixture.translate()); + fixture.mDraw->mTexture = diffuse; + + LLPointer normal = fixture.mDraw->mNormalMap; + fixture.mDraw->mNormalMap = nullptr; + ensure("the normal texture is required", !fixture.translate()); + fixture.mDraw->mNormalMap = normal; + + LLPointer specular = fixture.mDraw->mSpecularMap; + fixture.mDraw->mSpecularMap = nullptr; + ensure("the specular texture is required", !fixture.translate()); + fixture.mDraw->mSpecularMap = specular; + + LLPointer buffer = fixture.mDraw->mVertexBuffer; + fixture.mDraw->mVertexBuffer = nullptr; + ensure("the source vertex buffer is required", !fixture.translate()); + fixture.mDraw->mVertexBuffer = buffer; +} + +template<> +template<> +void draw_info_translator_test_object::test<5>() +{ + DrawInfoFixture fixture; + + { + ScopedNonOwningPointer gltf(fixture.mDraw->mGLTFMaterial, 0x1000); + ensure("GLTF material draws are outside this translation shape", !fixture.translate()); + } + { + ScopedNonOwningPointer avatar(fixture.mDraw->mAvatar, 0x2000); + ensure("avatar draws are outside this translation shape", !fixture.translate()); + } + + fixture.mDraw->mSkinInfo = reinterpret_cast(std::uintptr_t{ 0x3000 }); + ensure("skinned draws are outside this translation shape", !fixture.translate()); + fixture.mDraw->mSkinInfo = nullptr; + + fixture.mDraw->mHasGlow = true; + ensure("glow draws are outside this translation shape", !fixture.translate()); + fixture.mDraw->mHasGlow = false; + + LLMatrix4 auxiliary_matrix; + fixture.mDraw->mNormalMapMatrix = &auxiliary_matrix; + ensure("normal-map matrices are outside this translation shape", !fixture.translate()); + fixture.mDraw->mNormalMapMatrix = nullptr; + + fixture.mDraw->mSpecularMapMatrix = &auxiliary_matrix; + ensure("specular-map matrices are outside this translation shape", !fixture.translate()); + fixture.mDraw->mSpecularMapMatrix = nullptr; + + fixture.mDraw->mTextureList.push_back(fixture.mDiffuse); + ensure("a repeated diffuse texture-list entry remains supported", fixture.translate().has_value()); + fixture.mDraw->mTextureList.clear(); + + LLPointer distinct_texture = new LLViewerTexture(false); + fixture.mDraw->mTextureList.push_back(distinct_texture); + ensure("a distinct batched texture-list entry is rejected", !fixture.translate()); + fixture.mDraw->mTextureList.clear(); + + LLPointer incomplete = new LLVertexBuffer(REQUIRED_VERTEX_MASK & ~LLVertexBuffer::MAP_TANGENT); + LLPointer complete = fixture.mDraw->mVertexBuffer; + fixture.mDraw->mVertexBuffer = incomplete; + ensure("missing required vertex attributes are rejected", !fixture.translate()); + fixture.mDraw->mVertexBuffer = complete; +} + +template<> +template<> +void draw_info_translator_test_object::test<6>() +{ + DrawInfoFixture fixture; + + fixture.mDraw->mCount = 0; + ensure("empty index ranges are rejected", !fixture.translate()); + fixture.mDraw->mCount = 6; + + fixture.mDraw->mStart = 15; + fixture.mDraw->mEnd = 14; + ensure("inverted vertex ranges are rejected", !fixture.translate()); + fixture.mDraw->mStart = 11; + fixture.mDraw->mEnd = 14; + + fixture.mDraw->mEnd = 65; + ensure("vertex ranges outside the resolved record are rejected", !fixture.translate()); + fixture.mDraw->mEnd = 14; + + fixture.mDraw->mOffset = 64; + fixture.mDraw->mCount = 2; + ensure("index ranges outside the resolved record are rejected", !fixture.translate()); + fixture.mDraw->mOffset = 18; + fixture.mDraw->mCount = 6; + + const BufferHandle index_buffer = fixture.mResolver.mGeometry.mIndexBuffer; + fixture.mResolver.mGeometry.mIndexBuffer = fixture.mResolver.mGeometry.mVertexBuffer; + fixture.mResolver.mCurrentIndexBuffer = fixture.mResolver.mCurrentVertexBuffer; + ensure("one handle cannot alias the vertex and index streams", !fixture.translate()); + fixture.mResolver.mGeometry.mIndexBuffer = index_buffer; + fixture.mResolver.mCurrentIndexBuffer = index_buffer; + + const std::uint64_t vertex_bytes = fixture.mResolver.mGeometry.mVertexBufferSize; + fixture.mResolver.mGeometry.mVertexBufferSize = vertex_bytes - 1; + ensure("undersized resolved vertex storage is rejected", !fixture.translate()); + fixture.mResolver.mGeometry.mVertexBufferSize = vertex_bytes; + + const std::uint64_t index_bytes = fixture.mResolver.mGeometry.mIndexBufferSize; + fixture.mResolver.mGeometry.mIndexBufferSize = index_bytes - 1; + ensure("undersized resolved index storage is rejected", !fixture.translate()); + fixture.mResolver.mGeometry.mIndexBufferSize = index_bytes; + + fixture.mModelMatrix.mMatrix[0][0] = std::numeric_limits::infinity(); + ensure("non-finite model constants are rejected", !fixture.translate()); + fixture.mModelMatrix.mMatrix[0][0] = 1.f; + + fixture.mTextureMatrix.mMatrix[0][1] = std::numeric_limits::infinity(); + ensure("non-finite texture constants are rejected", !fixture.translate()); + fixture.mTextureMatrix.mMatrix[0][1] = 0.f; + + fixture.mDraw->mSpecColor.mV[2] = 1.01f; + ensure("out-of-range specular constants are rejected", !fixture.translate()); + fixture.mDraw->mSpecColor.mV[2] = 0.8f; + + fixture.mDraw->mEnvIntensity = -0.01f; + ensure("out-of-range environment constants are rejected", !fixture.translate()); + fixture.mDraw->mEnvIntensity = 0.625f; + + fixture.mDraw->mAlphaMaskCutoff = 1.01f; + ensure("out-of-range alpha constants are rejected", !fixture.translate()); + fixture.mDraw->mAlphaMaskCutoff = 0.375f; + + fixture.mResolver.mResolveGeometry = false; + ensure("a missing geometry resolution is rejected", !fixture.translate()); + fixture.mResolver.mResolveGeometry = true; + + fixture.mResolver.mResolveImages[roleIndex(TextureRole::Normal)] = false; + ensure("a missing image resolution is rejected", !fixture.translate()); + fixture.mResolver.mResolveImages[roleIndex(TextureRole::Normal)] = true; + + fixture.mResolver.mResolvePipeline = false; + ensure("a missing pipeline resolution is rejected", !fixture.translate()); + fixture.mResolver.mResolvePipeline = true; + + fixture.mResolver.mGeometryLive = false; + ensure("retired geometry returns no resolution", !fixture.translate()); + fixture.mResolver.mGeometryLive = true; + + fixture.mResolver.mImagesLive[roleIndex(TextureRole::Normal)] = false; + ensure("a retired image returns no resolution", !fixture.translate()); + fixture.mResolver.mImagesLive[roleIndex(TextureRole::Normal)] = true; + + fixture.mResolver.mPipelineLive = false; + ensure("a retired pipeline returns no resolution", !fixture.translate()); + fixture.mResolver.mPipelineLive = true; + + fixture.mResolver.mCurrentVertexBuffer.mGeneration = 8; + ensure("a nonzero stale buffer generation returns no resolution", !fixture.translate()); + fixture.mResolver.mCurrentVertexBuffer.mGeneration = 7; + + fixture.mResolver.mCurrentImages[roleIndex(TextureRole::Diffuse)].mGeneration = 5; + ensure("a nonzero stale image generation returns no resolution", !fixture.translate()); + fixture.mResolver.mCurrentImages[roleIndex(TextureRole::Diffuse)].mGeneration = 4; + + fixture.mResolver.mCurrentSamplers[roleIndex(TextureRole::Normal)].mGeneration = 4; + ensure("a nonzero stale sampler generation returns no resolution", !fixture.translate()); + fixture.mResolver.mCurrentSamplers[roleIndex(TextureRole::Normal)].mGeneration = 3; + + fixture.mResolver.mCurrentPipeline.mGeneration = 4; + ensure("a nonzero stale pipeline generation returns no resolution", !fixture.translate()); + fixture.mResolver.mCurrentPipeline.mGeneration = 3; + + fixture.mResolver.mGeometry.mVertexBuffer.mGeneration = 0; + ensure("a zero-generation geometry record is rejected", !fixture.translate()); + fixture.mResolver.mGeometry.mVertexBuffer.mGeneration = 7; + + DrawTextureInput& diffuse = fixture.mResolver.mImages[roleIndex(TextureRole::Diffuse)]; + diffuse.mImage.mGeneration = 0; + ensure("a zero-generation image record is rejected", !fixture.translate()); + diffuse.mImage.mGeneration = 4; + + fixture.mResolver.mPipeline.mGeneration = 0; + ensure("a zero-generation pipeline record is rejected", !fixture.translate()); + fixture.mResolver.mPipeline.mGeneration = 3; + + DrawTextureInput& specular = fixture.mResolver.mImages[roleIndex(TextureRole::Specular)]; + specular.mRange.mMipLevelCount = 0; + ensure("an invalid resolved image range is rejected", !fixture.translate()); + specular.mRange.mMipLevelCount = 2; +} + +template<> +template<> +void draw_info_translator_test_object::test<7>() +{ + DrawInfoFixture fixture; + fixture.mDraw->mNormalMap = fixture.mDiffuse; + fixture.mDraw->mSpecularMap = fixture.mDiffuse; + fixture.mResolver.mImageSources.fill(fixture.mDiffuse); + + const ImageHandle shared_image{ 3, 4 }; + fixture.mResolver.mImages[roleIndex(TextureRole::Normal)].mImage = shared_image; + fixture.mResolver.mImages[roleIndex(TextureRole::Specular)].mImage = shared_image; + fixture.mResolver.mCurrentImages[roleIndex(TextureRole::Normal)] = shared_image; + fixture.mResolver.mCurrentImages[roleIndex(TextureRole::Specular)] = shared_image; + + const auto packet = fixture.translate(); + ensure("one source texture may fill all three roles", packet.has_value()); + ensure("reused source keeps role-specific samplers", + packet->mDescriptors.mDiffuse.mSampler != packet->mDescriptors.mNormal.mSampler && + packet->mDescriptors.mNormal.mSampler != packet->mDescriptors.mSpecular.mSampler); + ensure("reused source resolves to one stable image identity", + packet->mDescriptors.mDiffuse.mImage == packet->mDescriptors.mNormal.mImage && + packet->mDescriptors.mNormal.mImage == packet->mDescriptors.mSpecular.mImage); + + const ImageHandle inconsistent_image{ 99, 1 }; + fixture.mResolver.mImages[roleIndex(TextureRole::Specular)].mImage = inconsistent_image; + fixture.mResolver.mCurrentImages[roleIndex(TextureRole::Specular)] = inconsistent_image; + ensure("one source texture cannot resolve to inconsistent image identities", !fixture.translate()); +} + +} // namespace tut diff --git a/indra/newview/tests/lldrawinfotranslator_texture_stub.cpp b/indra/newview/tests/lldrawinfotranslator_texture_stub.cpp new file mode 100644 index 00000000000..3d51928a937 --- /dev/null +++ b/indra/newview/tests/lldrawinfotranslator_texture_stub.cpp @@ -0,0 +1,105 @@ +/** + * @file lldrawinfotranslator_texture_stub.cpp + * @brief CPU-only LLViewerTexture implementation for draw-info translation tests. + * + * $LicenseInfo:firstyear=2026&license=viewerlgpl$ + * Second Life Viewer Source Code + * Copyright (C) 2026, Linden Research, Inc. + * + * This library is free software; you can redistribute it and/or + * modify it under the terms of the GNU Lesser General Public + * License as published by the Free Software Foundation; + * version 2.1 of the license only. + * $/LicenseInfo$ + */ + +#include "linden_common.h" + +#include "llspatialpartition.h" +#include "llviewertexture.h" + +#include + +LLDrawInfo::~LLDrawInfo() = default; + +LLViewerTexture::LLViewerTexture(bool usemipmaps) : + LLGLTexture(usemipmaps), + mTextureListType(LOCAL_TEXTURE), + mMaxVirtualSizeResetCounter(1), + mMaxVirtualSizeResetInterval(1), + mParcelMedia(nullptr) +{ + std::fill(std::begin(mNumFaces), std::end(mNumFaces), 0); + std::fill(std::begin(mNumVolumes), std::end(mNumVolumes), 0); +} + +LLViewerTexture::~LLViewerTexture() = default; + +S8 LLViewerTexture::getType() const +{ + return LOCAL_TEXTURE; +} + +bool LLViewerTexture::isMissingAsset() const +{ + return false; +} + +void LLViewerTexture::dump() +{ +} + +bool LLViewerTexture::bindDefaultImage(S32) +{ + return false; +} + +bool LLViewerTexture::bindDebugImage(S32) +{ + return false; +} + +void LLViewerTexture::forceImmediateUpdate() +{ +} + +bool LLViewerTexture::isActiveFetching() +{ + return false; +} + +void LLViewerTexture::setBoostLevel(S32 level) +{ + mBoostLevel = level; +} + +F32 LLViewerTexture::getMaxVirtualSize() +{ + return mMaxVirtualSize; +} + +void LLViewerTexture::setKnownDrawSize(S32 width, S32 height) +{ + mFullWidth = width; + mFullHeight = height; +} + +void LLViewerTexture::addFace(U32, LLFace*) +{ +} + +void LLViewerTexture::removeFace(U32, LLFace*) +{ +} + +void LLViewerTexture::addVolume(U32, LLVOVolume*) +{ +} + +void LLViewerTexture::removeVolume(U32, LLVOVolume*) +{ +} + +void LLViewerTexture::updateBindStatsForTester() +{ +} diff --git a/indra/newview/tests/test_vulkan_material_artifact_delivery.py b/indra/newview/tests/test_vulkan_material_artifact_delivery.py new file mode 100644 index 00000000000..e8fc7b8ab8e --- /dev/null +++ b/indra/newview/tests/test_vulkan_material_artifact_delivery.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +"""Focused tests for exact production material artifact delivery.""" + +from __future__ import annotations + +import os +from contextlib import contextmanager +from pathlib import Path +import shutil +import sys +import tempfile +import unittest + + +NEWVIEW_DIRECTORY = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(NEWVIEW_DIRECTORY)) + +from vulkan_material_artifact_delivery import ( # noqa: E402 + ArtifactDeliveryError, + DESTINATION_DIRECTORY, + PRODUCTION_ARTIFACTS, + deliver_production_material_artifacts, +) + + +class CopyManifest: + def __init__(self, destination_root: Path): + self._destination_root = destination_root + self._source_prefix = Path() + self._destination_prefix = destination_root + + def dst_path_of(self, relpath: str) -> str: + return str(self._destination_prefix / relpath) + + @contextmanager + def prefix(self, *, src: str, dst: str): + previous_source = self._source_prefix + previous_destination = self._destination_prefix + self._source_prefix = Path(src) + self._destination_prefix = self._destination_root / dst + try: + yield + finally: + self._source_prefix = previous_source + self._destination_prefix = previous_destination + + def path(self, src: str, dst: str | None = None) -> int: + source = self._source_prefix / src + output = self._destination_prefix / (dst or src) + output.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(source, output) + return 1 + + def remove(self, *paths: str) -> None: + for raw_path in paths: + path = Path(raw_path) + if path.is_dir(): + shutil.rmtree(path) + elif path.exists(): + path.unlink() + + +class FailingSecondCopyManifest(CopyManifest): + def __init__(self, destination_root: Path): + super().__init__(destination_root) + self._copy_count = 0 + + def path(self, src: str, dst: str | None = None) -> int: + self._copy_count += 1 + if self._copy_count == 2: + raise OSError("injected second-copy failure") + return super().path(src, dst) + + +class ArtifactDeliveryTest(unittest.TestCase): + def setUp(self) -> None: + self._temporary_directory = tempfile.TemporaryDirectory() + self._root = Path(self._temporary_directory.name) + self._source = self._root / "generated" + self._destination_root = self._root / "package" + self._source.mkdir() + self._manifest = CopyManifest(self._destination_root) + self._source_bytes = { + "material.production.vert.spv": b"vertex-production-bytes", + "material.production.frag.spv": b"fragment-production-bytes", + } + for name, contents in self._source_bytes.items(): + (self._source / name).write_bytes(contents) + + def tearDown(self) -> None: + self._temporary_directory.cleanup() + + @property + def destination(self) -> Path: + return self._destination_root / DESTINATION_DIRECTORY + + def test_delivers_only_exact_production_files_with_identical_bytes(self) -> None: + (self._source / "material.production.vert.reflect.json").write_text("{}") + (self._source / "material.vert.spv").write_bytes(b"diagnostic") + delivered = deliver_production_material_artifacts( + self._manifest, str(self._source) + ) + + expected_names = {destination for _, destination in PRODUCTION_ARTIFACTS} + self.assertEqual(expected_names, {path.name for path in delivered}) + self.assertEqual(expected_names, {path.name for path in self.destination.iterdir()}) + for source_name, destination_name in PRODUCTION_ARTIFACTS: + self.assertEqual( + self._source_bytes[source_name], + (self.destination / destination_name).read_bytes(), + ) + + def test_missing_source_fails_without_partial_delivery(self) -> None: + (self._source / "material.production.frag.spv").unlink() + self.destination.mkdir(parents=True) + (self.destination / "stale.spv").write_bytes(b"stale") + + with self.assertRaises(ArtifactDeliveryError): + deliver_production_material_artifacts(self._manifest, str(self._source)) + + self.assertFalse(self.destination.exists()) + + def test_reused_destination_is_cleaned_before_copy(self) -> None: + self.destination.mkdir(parents=True) + (self.destination / "stale.spv").write_bytes(b"stale") + (self.destination / "nested").mkdir() + (self.destination / "nested" / "evidence.json").write_text("{}") + + deliver_production_material_artifacts(self._manifest, str(self._source)) + + self.assertEqual( + {destination for _, destination in PRODUCTION_ARTIFACTS}, + {path.name for path in self.destination.iterdir()}, + ) + + def test_option_off_removes_the_dedicated_destination(self) -> None: + self.destination.mkdir(parents=True) + (self.destination / "production.vert.spv").write_bytes(b"stale") + + delivered = deliver_production_material_artifacts(self._manifest, "") + + self.assertEqual((), delivered) + self.assertFalse(self.destination.exists()) + + def test_second_copy_failure_removes_the_partial_pair(self) -> None: + manifest = FailingSecondCopyManifest(self._destination_root) + + with self.assertRaises(OSError): + deliver_production_material_artifacts(manifest, str(self._source)) + + self.assertFalse(self.destination.exists()) + + @unittest.skipUnless(hasattr(os, "symlink"), "symlinks are unavailable") + def test_stale_destination_symlink_is_unlinked_without_touching_target(self) -> None: + outside = self._root / "outside" + outside.mkdir() + sentinel = outside / "sentinel" + sentinel.write_bytes(b"keep") + self.destination.parent.mkdir(parents=True) + os.symlink(outside, self.destination, target_is_directory=True) + + deliver_production_material_artifacts(self._manifest, "") + + self.assertFalse(self.destination.exists()) + self.assertEqual(b"keep", sentinel.read_bytes()) + + @unittest.skipUnless(hasattr(os, "symlink"), "symlinks are unavailable") + def test_parent_symlink_is_rejected_without_touching_target(self) -> None: + outside = self._root / "outside" + outside.mkdir() + sentinel = outside / "sentinel" + sentinel.write_bytes(b"keep") + vulkan_parent = self._destination_root / "app_settings" / "shaders" / "vulkan" + vulkan_parent.parent.mkdir(parents=True) + os.symlink(outside, vulkan_parent, target_is_directory=True) + + with self.assertRaises(ArtifactDeliveryError): + deliver_production_material_artifacts(self._manifest, "") + + self.assertEqual(b"keep", sentinel.read_bytes()) + + @unittest.skipUnless(hasattr(os, "symlink"), "symlinks are unavailable") + def test_symlink_source_file_is_rejected(self) -> None: + vertex = self._source / "material.production.vert.spv" + actual = self._source / "actual.vert.spv" + vertex.rename(actual) + os.symlink(actual.name, vertex) + + with self.assertRaises(ArtifactDeliveryError): + deliver_production_material_artifacts(self._manifest, str(self._source)) + + self.assertFalse(self.destination.exists()) + + +if __name__ == "__main__": + unittest.main() diff --git a/indra/newview/viewer_manifest.py b/indra/newview/viewer_manifest.py index 326b3adaafc..3a012975ea2 100755 --- a/indra/newview/viewer_manifest.py +++ b/indra/newview/viewer_manifest.py @@ -50,6 +50,10 @@ sys.path.insert(0, os.path.join(viewer_dir, os.pardir, "lib", "python")) from indra.util.llmanifest import LLManifest, main, path_ancestors, CHANNEL_VENDOR_BASE, RELEASE_CHANNEL, ManifestError, MissingError import llsd +from vulkan_material_artifact_delivery import ( + ArtifactDeliveryError, + deliver_production_material_artifacts, +) class ViewerManifest(LLManifest): def is_packaging_viewer(self): @@ -197,6 +201,13 @@ def construct(self): if not self.path2basename(os.pardir, "build_data.json"): print("No build_data.json file") + try: + deliver_production_material_artifacts( + self, self.args.get('vulkan_material_artifact_dir', '') + ) + except ArtifactDeliveryError as error: + raise ManifestError(str(error)) from error + def finish_build_data_dict(self, build_data_dict): return build_data_dict @@ -1537,6 +1548,9 @@ def construct(self): dict(name='openal', description="""Indication openal libraries are needed""", default='OFF'), dict(name='tracy', description="""Indication tracy profiler is enabled""", default='OFF'), dict(name='velopack', description="""Use Velopack installer instead of NSIS""", default='OFF'), + dict(name='vulkan_material_artifact_dir', + description="""Directory containing the validated production material SPIR-V pair""", + default=''), ] try: main(extra=extra_arguments) diff --git a/indra/newview/vulkan_material_artifact_delivery.py b/indra/newview/vulkan_material_artifact_delivery.py new file mode 100644 index 00000000000..0b0b667e174 --- /dev/null +++ b/indra/newview/vulkan_material_artifact_delivery.py @@ -0,0 +1,123 @@ +"""Deliver the validated production material SPIR-V pair to app settings.""" + +from __future__ import annotations + +from pathlib import Path +from typing import Protocol + + +DESTINATION_DIRECTORY = "app_settings/shaders/vulkan/legacy_normspec" +PRODUCTION_ARTIFACTS = ( + ("material.production.vert.spv", "production.vert.spv"), + ("material.production.frag.spv", "production.frag.spv"), +) + + +class ArtifactDeliveryError(RuntimeError): + """The production artifacts could not be delivered exactly.""" + + +class Manifest(Protocol): + def dst_path_of(self, relpath: str) -> str: ... + + def prefix(self, *, src: str, dst: str): ... + + def path(self, src: str, dst: str | None = None) -> int: ... + + def remove(self, *paths: str) -> None: ... + + +def _clean_destination(manifest: Manifest, destination: Path) -> None: + # LLManifest.remove() intentionally follows normal directories. Handle a + # stale symlink as a file so cleanup cannot escape this dedicated path. + if destination.is_symlink(): + destination.unlink() + else: + manifest.remove(str(destination)) + + +def _validate_destination_parents(manifest: Manifest) -> Path: + destination_root = Path(manifest.dst_path_of("")) + if destination_root.is_symlink(): + raise ArtifactDeliveryError( + f"manifest destination root must not be a symlink: {destination_root}" + ) + + current = destination_root + components = Path(DESTINATION_DIRECTORY).parts + for component in components[:-1]: + current /= component + if current.is_symlink(): + raise ArtifactDeliveryError( + f"artifact destination parent must not be a symlink: {current}" + ) + return destination_root.joinpath(*components) + + +def deliver_production_material_artifacts( + manifest: Manifest, + artifact_directory: str, +) -> tuple[Path, ...]: + """Clean the dedicated destination and optionally copy the exact pair.""" + + destination = _validate_destination_parents(manifest) + _clean_destination(manifest, destination) + + if not artifact_directory: + return () + + source_root = Path(artifact_directory) + if source_root.is_symlink() or not source_root.is_dir(): + raise ArtifactDeliveryError( + f"production artifact directory is not a real directory: {source_root}" + ) + + snapshots: list[tuple[str, str, bytes]] = [] + for source_name, destination_name in PRODUCTION_ARTIFACTS: + source = source_root / source_name + if source.is_symlink() or not source.is_file(): + raise ArtifactDeliveryError( + f"production artifact is not a real file: {source}" + ) + try: + contents = source.read_bytes() + except OSError as error: + raise ArtifactDeliveryError( + f"production artifact is unreadable: {source}: {error}" + ) from error + snapshots.append((source_name, destination_name, contents)) + + delivered: list[Path] = [] + try: + with manifest.prefix(src=str(source_root), dst=DESTINATION_DIRECTORY): + for source_name, destination_name, expected_bytes in snapshots: + if manifest.path(source_name, destination_name) != 1: + raise ArtifactDeliveryError( + f"production artifact was not copied exactly once: {source_name}" + ) + output = destination / destination_name + try: + actual_bytes = output.read_bytes() + except OSError as error: + raise ArtifactDeliveryError( + f"delivered artifact is unreadable: {output}: {error}" + ) from error + if actual_bytes != expected_bytes: + raise ArtifactDeliveryError( + f"delivered artifact differs from validated input: {source_name}" + ) + delivered.append(output) + + actual_names = {path.name for path in destination.iterdir()} + expected_names = { + destination_name for _, destination_name in PRODUCTION_ARTIFACTS + } + if actual_names != expected_names: + raise ArtifactDeliveryError( + f"unexpected files in production artifact destination: {destination}" + ) + except Exception: + _clean_destination(manifest, destination) + raise + + return tuple(delivered) diff --git a/scripts/perf/fixtures/renderer-result-v3.json b/scripts/perf/fixtures/renderer-result-v3.json new file mode 100644 index 00000000000..e0c14ea916a --- /dev/null +++ b/scripts/perf/fixtures/renderer-result-v3.json @@ -0,0 +1,285 @@ +{ + "schema_version": 3, + "status": "valid", + "failure_reason": null, + "run": { + "scenario": "steady-warm-v1", + "run_number": 1, + "cache_mode": "warm", + "manifest_hash": "fixture-manifest-hash", + "settings_hash": "fixture-requested-settings-hash", + "warmup_seconds": 30, + "duration_seconds": 120, + "poll_interval_seconds": 0.5 + }, + "context": { + "backend_label": "native-gl", + "backing_height": 720, + "backing_scale_x": 2.0, + "backing_scale_y": 2.0, + "backing_width": 1280, + "build_type": "Release", + "configured_ui_scale": 0.5, + "cpu": "Fixture CPU", + "detected_backend": "native-gl", + "driver": "fixture-driver", + "effective_display_scale_x": 1.0, + "effective_display_scale_y": 1.0, + "effective_settings": {"RenderBenchmarkUIScale": 1.0, "RenderHiDPI": true, "RenderVSyncEnable": false}, + "effective_settings_hash": "fixture-effective-settings-hash", + "feature_flags": {"RenderBenchmarkUIScale": 1.0, "RenderHiDPI": true, "RenderVSyncEnable": false}, + "feature_flags_hash": "fixture-feature-flags-hash", + "git_commit": "0000000000000000000000000000000000000000", + "git_diff_hash": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", + "git_dirty": false, + "gpu": "Fixture GPU", + "gpu_vendor": "Fixture Vendor", + "gpu_vram_mb": 8192, + "hardware_label": "fixture-current-gpu", + "height": 720, + "logical_core_count": 16, + "logical_height": 360, + "logical_width": 640, + "opengl_profile": "core", + "opengl_version": "4.6 fixture", + "gl_extensions": ["GL_ARB_fixture"], + "gl_extensions_hash": "fixture-extensions-hash", + "gl_limits": { + "max_samples": 8, + "max_texture_image_units": 32, + "max_texture_size": 16384, + "max_uniform_block_size": 65536 + }, + "os": "Fixture OS", + "viewer_channel": "Second Life Test", + "viewer_version": "0.0.0.0", + "shader_level": 3, + "width": 1280 + }, + "instrumentation": { + "mode": "steady-low-overhead", + "compile_time_enabled": true, + "cpu_phase_timing": true, + "resource_counters": true, + "gpu_pass_timing": "external-diagnostic", + "gpu_query_readback_in_steady_loop": false + }, + "validity": { + "workload_id": "fixture-steady-scene", + "policy": { + "asset_mode": "settled", + "population_mode": "stable", + "ui_mode": "clear", + "settle_seconds": 15, + "max_camera_translation_m": 0.1, + "max_camera_rotation_rad": 0.05, + "max_agent_travel_m": 0.0, + "max_visible_avatar_delta": 0, + "max_active_object_delta": 0, + "max_new_objects": 0, + "max_sim_ping_ms": 600 + }, + "policy_hash": "0e33a3ef67a1c209d564ec66757b04832ef6ac3a04c404d485140e6f3dafc0b0", + "operator": { + "power_source": "ac", + "low_power_mode": "off", + "thermal_state": "nominal", + "scene_events": "none", + "ui_state": "approved", + "camera_state": "approved" + }, + "observed": { + "settle_seconds_observed": 15.5, + "destination_ready": true, + "teleport_seen": false, + "progress_seen": false, + "background_frame_count": 0, + "camera_animating_seen": false, + "camera_translation_m": 0.01, + "camera_rotation_rad": 0.001, + "agent_travel_m": 0.0, + "agent_speed_mps_max": 0.0, + "view_hash": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "modal_dialog_max": 0, + "alert_toast_seen": false, + "welcome_pack_seen": false, + "hint_seen": false, + "closeable_floaters_closed": true, + "texture_fetch_requests_max": 0, + "texture_http_requests_max": 0, + "texture_create_queue_max": 0, + "texture_fast_cache_max": 0, + "texture_upload_count_delta": 0.0, + "mesh_lod_unresolved_max": 0, + "mesh_skin_unresolved_max": 0, + "self_avatar_loaded": true, + "visible_avatars_min": 1, + "visible_avatars_max": 1, + "active_objects_min": 100, + "active_objects_max": 100, + "new_objects_total": 0.0, + "sim_ping_ms_max": 45.0, + "circuit_healthy": true, + "pings_in_transit_max": 0, + "packets_in_delta": 240.0, + "packets_lost_delta": 0.0 + }, + "gates": { + "workload": true, + "placement": true, + "focus": true, + "camera": true, + "avatar": true, + "ui": true, + "assets": true, + "population": true, + "network": true, + "scene_events": true, + "power": true, + "thermal": true + } + }, + "frames": [ + { + "frame_number": 100, + "frame_time_ms": 10.0, + "geometry_create_ms": 0.2, + "partition_ms": 0.1, + "geometry_update_ms": 0.3, + "cull_ms": 0.4, + "state_sort_ms": 0.5, + "submission_ms": 2.0, + "lighting_ms": 1.0, + "ui_ms": 0.8, + "swap_ms": 0.1, + "idle_ms": 1.0, + "unclassified_ms": 3.6, + "draw_calls": 800, + "batch_size_mean": 24.0, + "ktriangles": 240.0, + "texture_upload_count_total": 10, + "texture_upload_bytes_total": 1000, + "texture_readback_count_total": 0, + "texture_readback_time_us_total": 0, + "texture_wait_count_total": 1, + "texture_wait_time_us_total": 20, + "shader_compile_count_total": 200, + "shader_compile_time_us_total": 500000, + "shader_bind_count_total": 1000 + }, + { + "frame_number": 101, + "frame_time_ms": 12.0, + "geometry_create_ms": 0.2, + "partition_ms": 0.1, + "geometry_update_ms": 0.4, + "cull_ms": 0.5, + "state_sort_ms": 0.6, + "submission_ms": 2.4, + "lighting_ms": 1.2, + "ui_ms": 0.9, + "swap_ms": 0.1, + "idle_ms": 1.1, + "unclassified_ms": 4.5, + "draw_calls": 820, + "batch_size_mean": 23.0, + "ktriangles": 250.0, + "texture_upload_count_total": 12, + "texture_upload_bytes_total": 3048, + "texture_readback_count_total": 0, + "texture_readback_time_us_total": 0, + "texture_wait_count_total": 1, + "texture_wait_time_us_total": 20, + "shader_compile_count_total": 200, + "shader_compile_time_us_total": 500000, + "shader_bind_count_total": 1080 + }, + { + "frame_number": 102, + "frame_time_ms": 11.0, + "geometry_create_ms": 0.2, + "partition_ms": 0.1, + "geometry_update_ms": 0.3, + "cull_ms": 0.4, + "state_sort_ms": 0.5, + "submission_ms": 2.1, + "lighting_ms": 1.0, + "ui_ms": 0.8, + "swap_ms": 0.1, + "idle_ms": 1.0, + "unclassified_ms": 4.5, + "draw_calls": 805, + "batch_size_mean": 24.0, + "ktriangles": 242.0, + "texture_upload_count_total": 12, + "texture_upload_bytes_total": 3048, + "texture_readback_count_total": 0, + "texture_readback_time_us_total": 0, + "texture_wait_count_total": 2, + "texture_wait_time_us_total": 50, + "shader_compile_count_total": 200, + "shader_compile_time_us_total": 500000, + "shader_bind_count_total": 1160 + }, + { + "frame_number": 103, + "frame_time_ms": 30.0, + "geometry_create_ms": 0.4, + "partition_ms": 0.2, + "geometry_update_ms": 0.8, + "cull_ms": 0.9, + "state_sort_ms": 1.0, + "submission_ms": 8.0, + "lighting_ms": 4.0, + "ui_ms": 1.2, + "swap_ms": 0.2, + "idle_ms": 1.4, + "unclassified_ms": 11.9, + "draw_calls": 1200, + "batch_size_mean": 18.0, + "ktriangles": 400.0, + "texture_upload_count_total": 14, + "texture_upload_bytes_total": 7144, + "texture_readback_count_total": 1, + "texture_readback_time_us_total": 300, + "texture_wait_count_total": 3, + "texture_wait_time_us_total": 150, + "shader_compile_count_total": 201, + "shader_compile_time_us_total": 502000, + "shader_bind_count_total": 1300 + } + ], + "summary": { + "sample_count": 4, + "frame_time_ms": { + "median": 11.5, + "p95": 27.299999999999994, + "p99": 29.459999999999997, + "worst": 30.0 + }, + "one_percent_low_fps": 33.333333333333336, + "phase_p95_ms": { + "cull_ms": 0.8399999999999999, + "geometry_create_ms": 0.36999999999999994, + "geometry_update_ms": 0.7399999999999999, + "idle_ms": 1.355, + "lighting_ms": 3.579999999999999, + "partition_ms": 0.18499999999999997, + "state_sort_ms": 0.9399999999999998, + "submission_ms": 7.159999999999998, + "swap_ms": 0.18499999999999997, + "ui_ms": 1.1549999999999998 + }, + "counter_deltas": { + "shader_bind_count": 300.0, + "shader_compile_count": 1.0, + "shader_compile_time_us": 2000.0, + "texture_readback_count": 1.0, + "texture_readback_time_us": 300.0, + "texture_upload_bytes": 6144.0, + "texture_upload_count": 4.0, + "texture_wait_count": 2.0, + "texture_wait_time_us": 130.0 + } + } +} diff --git a/scripts/perf/render_benchmark.py b/scripts/perf/render_benchmark.py new file mode 100755 index 00000000000..73dc982337e --- /dev/null +++ b/scripts/perf/render_benchmark.py @@ -0,0 +1,1764 @@ +#!/usr/bin/env python3 +"""Run, validate, and summarize reproducible renderer benchmarks.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import json +import math +import os +from pathlib import Path +import re +import shlex +import stat +import statistics +import subprocess +import sys +import tempfile +from typing import Any, Iterable, Mapping, Sequence +import xml.etree.ElementTree as ET + + +SCHEMA_VERSION = 3 +DEFAULT_STARTUP_TIMEOUT_SECONDS = 180 +MIN_COMPARISON_REPEATS = 5 +BENCHMARK_BACKING_WIDTH = 1280 +BENCHMARK_BACKING_HEIGHT = 720 +BENCHMARK_EFFECTIVE_UI_SCALE = 1.0 +DISPLAY_SCALE_TOLERANCE = 1e-4 +OPERATIONAL_SETTINGS = { + "AllowMultipleViewers": True, + "FirstLoginThisInstall": False, + "MigrateCacheDirectory": False, + "SLURLPassToOtherInstance": False, +} +OPERATIONAL_SWITCHES = ( + "--multiple", + "--skipupdatecheck", + "--noaudio", + "--nonotifications", + "--novoice", +) +CACHE_FAILURE_SIGNATURES = ( + ("Failure in vf.write()", "asset-cache-write"), + ("Unable to set cache location", "cache-location"), + ("Unable to write header entry!", "texture-cache-header-write"), + ("writeToFastCache failed", "texture-fast-cache-write"), + ("Failed to write cache to disk", "object-cache-write"), + ("Failed to write cache entry to disk", "object-cache-entry-write"), + ("Failed to write cache. Unable to save inventory", "inventory-cache-write"), + ("Couldn't mkdir", "cache-directory-create"), +) +AVATAR_BLOCKER_SIGNATURES = ( + ("COF info is not complete", "cof-incomplete"), + ("Self is clouded due to missing one or more required body parts", "required-bodyparts-missing"), + ("Self is clouded because of no hair texture", "hair-texture-missing"), + ("Self is clouded because lower textures not baked", "lower-bake-missing"), + ("Self is clouded because upper textures not baked", "upper-bake-missing"), + ("Self is clouded because texture at index", "baked-texture-not-renderable"), +) +REQUIRED_APPEARANCE_PARTS = ("shape", "skin", "hair", "eyes") +APPEARANCE_CLASSIFICATIONS = frozenset({ + "avatar-unavailable", + "cof-incomplete", + "required-link-missing-or-unresolved", + "wearable-delivery-pending-or-failed", + "avatar-later-blocker", + "ready", + "unknown", +}) +READINESS_ASSET_FIELDS = ( + "mesh_lod_unresolved_max", + "mesh_skin_unresolved_max", + "texture_create_queue_max", + "texture_fast_cache_max", + "texture_fetch_requests_max", + "texture_http_requests_max", + "texture_upload_count_delta", +) +REQUIRED_CONTEXT_FIELDS = { + "backend_label", + "backing_height", + "backing_scale_x", + "backing_scale_y", + "backing_width", + "build_type", + "configured_ui_scale", + "cpu", + "detected_backend", + "driver", + "effective_display_scale_x", + "effective_display_scale_y", + "effective_settings", + "git_commit", + "git_diff_hash", + "git_dirty", + "gpu", + "gpu_vendor", + "hardware_label", + "height", + "logical_core_count", + "logical_height", + "logical_width", + "opengl_profile", + "opengl_version", + "os", + "viewer_version", + "width", +} +COMPARISON_FIELDS = ( + ("run", "scenario"), + ("run", "cache_mode"), + ("run", "manifest_hash"), + ("run", "settings_hash"), + ("context", "effective_settings_hash"), + ("context", "feature_flags_hash"), + ("context", "git_commit"), + ("context", "git_diff_hash"), + ("context", "git_dirty"), + ("context", "build_type"), + ("context", "width"), + ("context", "height"), + ("context", "backing_width"), + ("context", "backing_height"), + ("context", "logical_width"), + ("context", "logical_height"), + ("context", "backing_scale_x"), + ("context", "backing_scale_y"), + ("context", "configured_ui_scale"), + ("context", "effective_display_scale_x"), + ("context", "effective_display_scale_y"), + ("instrumentation", "mode"), + ("validity", "workload_id"), + ("validity", "policy_hash"), + ("validity", "operator", "power_source"), + ("validity", "operator", "low_power_mode"), + ("validity", "operator", "thermal_state"), + ("validity", "operator", "scene_events"), + ("validity", "operator", "ui_state"), + ("validity", "operator", "camera_state"), + ("validity", "observed", "view_hash"), + ("validity", "observed", "visible_avatars_min"), + ("validity", "observed", "active_objects_min"), +) +PRIVATE_KEYS = { + "account", + "account_id", + "agent_id", + "credential", + "credentials", + "destination", + "hostname", + "login", + "machine", + "machine_id", + "parcel", + "parcel_id", + "password", + "position", + "orientation", + "origin", + "region", + "region_id", + "serial_number", + "slurl", + "username", + "view", +} +WORKLOAD_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]{2,63}$") +VALIDITY_FIELDS = { + "asset_mode", + "max_active_object_delta", + "max_agent_travel_m", + "max_camera_rotation_rad", + "max_camera_translation_m", + "max_new_objects", + "max_sim_ping_ms", + "max_visible_avatar_delta", + "population_mode", + "settle_seconds", + "ui_mode", +} +OPERATOR_STATE_FIELDS = { + "power_source", + "low_power_mode", + "thermal_state", + "scene_events", + "ui_state", + "camera_state", +} +VALIDITY_GATE_NAMES = ( + "workload", + "placement", + "focus", + "camera", + "avatar", + "ui", + "assets", + "population", + "network", + "scene_events", + "power", + "thermal", +) +READINESS_TARGET_GATES = ("assets", "avatar") +CACHE_SENTINEL_NAME = "sl_cache_renderer_benchmark_sentinel.asset" +CACHE_SENTINEL_CONTENT = b"renderer-benchmark-cache-sentinel" + + +class BenchmarkError(ValueError): + """A benchmark artifact or invocation violates the benchmark contract.""" + + +def canonical_hash(value: Any) -> str: + encoded = json.dumps(value, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + return hashlib.sha256(encoded.encode("utf-8")).hexdigest() + + +def llsd_string_notation(value: str) -> str: + """Encode a string for viewer command-line settings that use LLSD notation.""" + return json.dumps(value, ensure_ascii=False) + + +def percentile(values: Sequence[float], quantile: float) -> float: + if not values: + raise BenchmarkError("cannot calculate a percentile without samples") + if not 0.0 <= quantile <= 1.0: + raise BenchmarkError("quantile must be between zero and one") + ordered = sorted(float(value) for value in values) + position = (len(ordered) - 1) * quantile + lower = math.floor(position) + upper = math.ceil(position) + if lower == upper: + return ordered[lower] + weight = position - lower + return ordered[lower] * (1.0 - weight) + ordered[upper] * weight + + +def sanitize(value: Any) -> Any: + """Remove identifying fields before an artifact can be written or reported.""" + if isinstance(value, Mapping): + return { + str(key): sanitize(item) + for key, item in value.items() + if str(key).strip().lower() not in PRIVATE_KEYS + } + if isinstance(value, list): + return [sanitize(item) for item in value] + return value + + +def safe_appearance_attribution(value: Any) -> dict[str, Any]: + """Project live appearance data into the fixed privacy-safe contract.""" + source = value if isinstance(value, Mapping) else {} + scalar_fields = ( + "avatar_valid", + "cof_present", + "cof_complete", + "cof_change_in_progress", + "avatar_loaded", + ) + + def required_parts(field: str) -> dict[str, bool]: + parts = source.get(field, {}) + if not isinstance(parts, Mapping): + parts = {} + return {name: parts.get(name) is True for name in REQUIRED_APPEARANCE_PARTS} + + projected = { + "avatar_valid": source.get("avatar_valid") is True, + "cof_present": source.get("cof_present") is True, + "cof_complete": source.get("cof_complete") is True, + "cof_change_in_progress": source.get("cof_change_in_progress") is True, + "required_links_resolved": required_parts("required_links_resolved"), + "required_wearables_delivered": required_parts("required_wearables_delivered"), + "avatar_loaded": source.get("avatar_loaded") is True, + } + parts_are_typed = all( + isinstance(source.get(field), Mapping) + and all(type(source[field].get(name)) is bool for name in REQUIRED_APPEARANCE_PARTS) + for field in ("required_links_resolved", "required_wearables_delivered") + ) + facts_are_typed = all(type(source.get(field)) is bool for field in scalar_fields) + expected_classification = ( + "avatar-unavailable" + if not projected["avatar_valid"] + else "cof-incomplete" + if not projected["cof_present"] or not projected["cof_complete"] + else "required-link-missing-or-unresolved" + if not all(projected["required_links_resolved"].values()) + else "wearable-delivery-pending-or-failed" + if not all(projected["required_wearables_delivered"].values()) + else "avatar-later-blocker" + if not projected["avatar_loaded"] + else "ready" + ) + classification = source.get("classification") + classification_is_known = ( + isinstance(classification, str) + and classification in APPEARANCE_CLASSIFICATIONS + ) + if ( + not facts_are_typed + or not parts_are_typed + or not classification_is_known + or classification not in {expected_classification, "unknown"} + ): + classification = "unknown" + return {"classification": classification, **projected} + + +def find_private_paths(value: Any, prefix: str = "$") -> list[str]: + found: list[str] = [] + if isinstance(value, Mapping): + for key, item in value.items(): + path = f"{prefix}.{key}" + if str(key).strip().lower() in PRIVATE_KEYS: + found.append(path) + found.extend(find_private_paths(item, path)) + elif isinstance(value, list): + for index, item in enumerate(value): + found.extend(find_private_paths(item, f"{prefix}[{index}]")) + return found + + +def load_json(path: Path) -> Any: + try: + return json.loads(path.read_text(encoding="utf-8")) + except (OSError, json.JSONDecodeError) as error: + raise BenchmarkError(f"cannot read JSON from {path}: {error}") from error + + +def validate_validity_policy(validity: Any, warmup_seconds: Any | None = None) -> None: + if not isinstance(validity, Mapping): + raise BenchmarkError("validity must be an object") + validity_missing = sorted(VALIDITY_FIELDS - validity.keys()) + validity_unknown = sorted(validity.keys() - VALIDITY_FIELDS) + if validity_missing: + raise BenchmarkError("validity is missing: " + ", ".join(validity_missing)) + if validity_unknown: + raise BenchmarkError("validity has unknown fields: " + ", ".join(validity_unknown)) + if validity["asset_mode"] not in {"settled", "streaming"}: + raise BenchmarkError("validity.asset_mode must be 'settled' or 'streaming'") + if validity["population_mode"] not in {"stable", "observed"}: + raise BenchmarkError("validity.population_mode must be 'stable' or 'observed'") + if validity["ui_mode"] not in {"clear", "controlled"}: + raise BenchmarkError("validity.ui_mode must be 'clear' or 'controlled'") + for field in ( + "settle_seconds", + "max_camera_rotation_rad", + "max_camera_translation_m", + "max_sim_ping_ms", + ): + if not _is_number(validity[field]) or validity[field] <= 0: + raise BenchmarkError(f"validity.{field} must be positive") + if not _is_number(validity["max_agent_travel_m"]) or validity["max_agent_travel_m"] < 0: + raise BenchmarkError("validity.max_agent_travel_m must be non-negative") + if warmup_seconds is not None and validity["settle_seconds"] > warmup_seconds: + raise BenchmarkError("validity.settle_seconds must fit inside capture.warmup_seconds") + for field in ("max_active_object_delta", "max_new_objects", "max_visible_avatar_delta"): + if not isinstance(validity[field], int) or isinstance(validity[field], bool) or validity[field] < 0: + raise BenchmarkError(f"validity.{field} must be a non-negative integer") + + +def validate_manifest(manifest: Mapping[str, Any]) -> None: + required = { + "schema_version", "id", "description", "cache_mode", "capture", "settings", "workload", "validity" + } + missing = sorted(required - manifest.keys()) + if missing: + raise BenchmarkError(f"manifest is missing: {', '.join(missing)}") + if manifest["schema_version"] != SCHEMA_VERSION: + raise BenchmarkError(f"unsupported manifest schema {manifest['schema_version']!r}") + if manifest["cache_mode"] not in {"warm", "cold"}: + raise BenchmarkError("cache_mode must be 'warm' or 'cold'") + capture = manifest["capture"] + if not isinstance(capture, Mapping): + raise BenchmarkError("capture must be an object") + for field in ("warmup_seconds", "duration_seconds", "repeats", "poll_interval_seconds"): + if not isinstance(capture.get(field), (int, float)) or capture[field] <= 0: + raise BenchmarkError(f"capture.{field} must be positive") + if not isinstance(manifest["settings"], Mapping): + raise BenchmarkError("settings must be an object") + settings = manifest["settings"] + expected_settings = { + "WindowWidth": BENCHMARK_BACKING_WIDTH, + "WindowHeight": BENCHMARK_BACKING_HEIGHT, + "WindowMaximized": False, + "RenderBenchmarkUIScale": BENCHMARK_EFFECTIVE_UI_SCALE, + "RenderHiDPI": True, + } + display_mismatches = [ + f"{name}={settings.get(name)!r}, required {expected!r}" + for name, expected in expected_settings.items() + if settings.get(name) != expected + ] + if "UIScaleFactor" in settings: + display_mismatches.append("UIScaleFactor must be derived from the detected backing scale") + if display_mismatches: + raise BenchmarkError("manifest display contract mismatch: " + "; ".join(display_mismatches)) + validate_validity_policy(manifest["validity"], capture["warmup_seconds"]) + if find_private_paths(manifest): + raise BenchmarkError("manifest contains a private field; locations and credentials are operator input") + + +def validate_operator_state(workload_id: Any, operator: Any) -> None: + if not isinstance(workload_id, str) or not WORKLOAD_ID_PATTERN.fullmatch(workload_id): + raise BenchmarkError("workload ID must be a 3-64 character lowercase slug") + if not isinstance(operator, Mapping): + raise BenchmarkError("operator state must be an object") + missing = sorted(OPERATOR_STATE_FIELDS - operator.keys()) + unknown = sorted(operator.keys() - OPERATOR_STATE_FIELDS) + if missing: + raise BenchmarkError("operator state is missing: " + ", ".join(missing)) + if unknown: + raise BenchmarkError("operator state has unknown fields: " + ", ".join(unknown)) + allowed = { + "power_source": {"ac", "battery", "unknown"}, + "low_power_mode": {"off", "on", "unknown"}, + "thermal_state": {"nominal", "elevated", "throttled", "unknown"}, + "scene_events": {"none", "observed", "unknown"}, + "ui_state": {"approved", "unapproved", "unknown"}, + "camera_state": {"approved", "unapproved", "unknown"}, + } + for field, choices in allowed.items(): + if operator[field] not in choices: + raise BenchmarkError(f"operator state {field}={operator[field]!r} is not supported") + + +def validity_gate_results( + workload_id: Any, + operator: Mapping[str, Any], + observed: Mapping[str, Any], + policy: Mapping[str, Any], +) -> dict[str, bool]: + """Derive fail-closed scene gates from a privacy-safe observation summary.""" + validate_operator_state(workload_id, operator) + validate_validity_policy(policy) + required_observed = { + "active_objects_max", + "active_objects_min", + "agent_travel_m", + "alert_toast_seen", + "background_frame_count", + "camera_animating_seen", + "camera_rotation_rad", + "camera_translation_m", + "circuit_healthy", + "closeable_floaters_closed", + "destination_ready", + "hint_seen", + "mesh_lod_unresolved_max", + "mesh_skin_unresolved_max", + "modal_dialog_max", + "new_objects_total", + "progress_seen", + "self_avatar_loaded", + "settle_seconds_observed", + "sim_ping_ms_max", + "teleport_seen", + "texture_create_queue_max", + "texture_fast_cache_max", + "texture_fetch_requests_max", + "texture_http_requests_max", + "texture_upload_count_delta", + "visible_avatars_max", + "visible_avatars_min", + "view_hash", + "welcome_pack_seen", + } + if not isinstance(observed, Mapping) or required_observed - observed.keys(): + return {name: False for name in VALIDITY_GATE_NAMES} + + numeric = lambda name: observed[name] if _is_number(observed[name]) else math.inf + integer = lambda name: observed[name] if isinstance(observed[name], int) and not isinstance(observed[name], bool) else math.inf + valid_view_hash = ( + isinstance(observed["view_hash"], str) + and re.fullmatch(r"[a-f0-9]{64}", observed["view_hash"]) is not None + ) + assets_settled = all( + integer(field) == 0 + for field in ( + "texture_fetch_requests_max", + "texture_http_requests_max", + "texture_create_queue_max", + "texture_fast_cache_max", + "mesh_lod_unresolved_max", + "mesh_skin_unresolved_max", + ) + ) and numeric("texture_upload_count_delta") == 0 + population_stable = ( + integer("visible_avatars_max") - integer("visible_avatars_min") + <= policy["max_visible_avatar_delta"] + and integer("active_objects_max") - integer("active_objects_min") + <= policy["max_active_object_delta"] + and numeric("new_objects_total") <= policy["max_new_objects"] + ) + ui_clear = ( + integer("modal_dialog_max") == 0 + and observed["alert_toast_seen"] is False + and observed["welcome_pack_seen"] is False + and observed["hint_seen"] is False + and observed["progress_seen"] is False + and (policy["ui_mode"] == "controlled" or observed["closeable_floaters_closed"] is True) + ) + return { + "workload": True, + "placement": observed["destination_ready"] is True and observed["teleport_seen"] is False, + "focus": integer("background_frame_count") == 0, + "camera": ( + observed["camera_animating_seen"] is False + and valid_view_hash + and numeric("camera_translation_m") <= policy["max_camera_translation_m"] + and numeric("camera_rotation_rad") <= policy["max_camera_rotation_rad"] + and operator["camera_state"] == "approved" + ), + "avatar": ( + observed["self_avatar_loaded"] is True + and numeric("agent_travel_m") <= policy["max_agent_travel_m"] + ), + "ui": ui_clear and operator["ui_state"] == "approved", + "assets": policy["asset_mode"] == "streaming" or ( + numeric("settle_seconds_observed") >= policy["settle_seconds"] and assets_settled + ), + "population": policy["population_mode"] == "observed" or population_stable, + "network": ( + observed["circuit_healthy"] is True + and 0 <= numeric("sim_ping_ms_max") <= policy["max_sim_ping_ms"] + ), + "scene_events": operator["scene_events"] == "none", + "power": operator["power_source"] in {"ac", "battery"} and operator["low_power_mode"] == "off", + "thermal": operator["thermal_state"] in {"nominal", "elevated"}, + } + + +def _frame_times(result: Mapping[str, Any]) -> list[float]: + times = [ + float(frame["frame_time_ms"]) + for frame in result.get("frames", []) + if isinstance(frame, Mapping) and isinstance(frame.get("frame_time_ms"), (int, float)) + ] + if not times: + raise BenchmarkError("result has no numeric frame_time_ms samples") + return times + + +def _counter_delta(frames: Sequence[Mapping[str, Any]], field: str) -> float: + samples = [float(frame[field]) for frame in frames if isinstance(frame.get(field), (int, float))] + return max(0.0, samples[-1] - samples[0]) if len(samples) >= 2 else 0.0 + + +def summarize_result(result: Mapping[str, Any]) -> dict[str, Any]: + times = _frame_times(result) + slow_count = max(1, math.ceil(len(times) * 0.01)) + slow_mean = statistics.fmean(sorted(times, reverse=True)[:slow_count]) + frames = [frame for frame in result["frames"] if isinstance(frame, Mapping)] + phase_fields = ( + "geometry_create_ms", + "partition_ms", + "geometry_update_ms", + "cull_ms", + "shadows_ms", + "texture_work_ms", + "state_sort_ms", + "rebuild_ms", + "submission_ms", + "lighting_ms", + "ui_ms", + "swap_ms", + "idle_ms", + ) + phase_p95_ms: dict[str, float] = {} + for field in phase_fields: + values = [float(frame[field]) for frame in frames if isinstance(frame.get(field), (int, float))] + if values: + phase_p95_ms[field] = percentile(values, 0.95) + + return { + "sample_count": len(times), + "frame_time_ms": { + "median": percentile(times, 0.5), + "p95": percentile(times, 0.95), + "p99": percentile(times, 0.99), + "worst": max(times), + }, + "one_percent_low_fps": 1000.0 / slow_mean if slow_mean > 0 else 0.0, + "phase_p95_ms": phase_p95_ms, + "counter_deltas": { + "texture_upload_count": _counter_delta(frames, "texture_upload_count_total"), + "texture_upload_bytes": _counter_delta(frames, "texture_upload_bytes_total"), + "texture_readback_count": _counter_delta(frames, "texture_readback_count_total"), + "texture_readback_time_us": _counter_delta(frames, "texture_readback_time_us_total"), + "texture_wait_count": _counter_delta(frames, "texture_wait_count_total"), + "texture_wait_time_us": _counter_delta(frames, "texture_wait_time_us_total"), + "shader_compile_count": _counter_delta(frames, "shader_compile_count_total"), + "shader_compile_time_us": _counter_delta(frames, "shader_compile_time_us_total"), + "shader_bind_count": _counter_delta(frames, "shader_bind_count_total"), + }, + } + + +def _is_number(value: Any) -> bool: + return isinstance(value, (int, float)) and not isinstance(value, bool) + + +def display_contract_mismatches(context: Mapping[str, Any], target_scale: Any) -> list[str]: + mismatches: list[str] = [] + dimension_fields = ( + "width", + "height", + "backing_width", + "backing_height", + "logical_width", + "logical_height", + ) + for field in dimension_fields: + value = context.get(field) + if not isinstance(value, int) or isinstance(value, bool) or value <= 0: + mismatches.append(f"actual {field}={value!r}, required a positive integer") + + for explicit, legacy in (("backing_width", "width"), ("backing_height", "height")): + if context.get(explicit) != context.get(legacy): + mismatches.append(f"{explicit}={context.get(explicit)!r}, {legacy}={context.get(legacy)!r}") + for field, expected in ( + ("backing_width", BENCHMARK_BACKING_WIDTH), + ("backing_height", BENCHMARK_BACKING_HEIGHT), + ): + if context.get(field) != expected: + mismatches.append(f"actual {field}={context.get(field)!r}, required {expected!r}") + + numeric_fields = ( + "backing_scale_x", + "backing_scale_y", + "configured_ui_scale", + "effective_display_scale_x", + "effective_display_scale_y", + ) + for field in numeric_fields: + value = context.get(field) + if not _is_number(value) or value <= 0: + mismatches.append(f"actual {field}={value!r}, required a positive number") + + if not _is_number(target_scale) or target_scale <= 0: + mismatches.append(f"effective RenderBenchmarkUIScale={target_scale!r}, required a positive number") + else: + for axis in ("x", "y"): + backing_scale = context.get(f"backing_scale_{axis}") + configured_scale = context.get("configured_ui_scale") + effective_scale = context.get(f"effective_display_scale_{axis}") + if _is_number(backing_scale) and _is_number(configured_scale): + derived_scale = float(backing_scale) * float(configured_scale) + if not math.isclose(derived_scale, float(target_scale), abs_tol=DISPLAY_SCALE_TOLERANCE): + mismatches.append( + f"configured UI scale produces {derived_scale!r} on {axis}, requested {target_scale!r}" + ) + if _is_number(effective_scale) and not math.isclose( + float(effective_scale), float(target_scale), abs_tol=DISPLAY_SCALE_TOLERANCE + ): + mismatches.append( + f"actual effective_display_scale_{axis}={effective_scale!r}, requested {target_scale!r}" + ) + + for axis, backing_field, logical_field in ( + ("x", "backing_width", "logical_width"), + ("y", "backing_height", "logical_height"), + ): + backing_size = context.get(backing_field) + logical_size = context.get(logical_field) + backing_scale = context.get(f"backing_scale_{axis}") + if all(_is_number(value) for value in (backing_size, logical_size, backing_scale)): + reconstructed = float(logical_size) * float(backing_scale) + if not math.isclose(reconstructed, float(backing_size), abs_tol=1.0): + mismatches.append( + f"logical {axis} geometry reconstructs {reconstructed!r} backing pixels, actual {backing_size!r}" + ) + return mismatches + + +def validate_result(result: Mapping[str, Any], require_valid: bool = True) -> None: + if result.get("schema_version") != SCHEMA_VERSION: + raise BenchmarkError(f"unsupported result schema {result.get('schema_version')!r}") + for field in ("status", "run", "context", "instrumentation", "frames", "validity"): + if field not in result: + raise BenchmarkError(f"result is missing {field}") + if result["status"] not in {"valid", "invalid"}: + raise BenchmarkError("status must be 'valid' or 'invalid'") + if require_valid and result["status"] != "valid": + raise BenchmarkError(f"invalid run: {result.get('failure_reason') or 'no reason supplied'}") + if not isinstance(result["frames"], list): + raise BenchmarkError("frames must be an array") + validity = result["validity"] + if not isinstance(validity, Mapping): + raise BenchmarkError("result validity must be an object") + for field in ("workload_id", "policy", "policy_hash", "operator", "observed", "gates"): + if field not in validity: + raise BenchmarkError(f"result validity is missing {field}") + policy = validity["policy"] + if not isinstance(policy, Mapping) or canonical_hash(policy) != validity["policy_hash"]: + raise BenchmarkError("result validity policy hash does not match its policy") + observed = validity["observed"] + if not isinstance(observed, Mapping): + raise BenchmarkError("result validity observations must be an object") + if "appearance" in observed: + safe_appearance = safe_appearance_attribution(observed["appearance"]) + if canonical_hash(observed["appearance"]) != canonical_hash(safe_appearance): + raise BenchmarkError("result appearance attribution is outside the privacy-safe contract") + validate_operator_state(validity["workload_id"], validity["operator"]) + expected_gates = validity_gate_results( + validity["workload_id"], validity["operator"], validity["observed"], policy + ) + if validity["gates"] != expected_gates: + raise BenchmarkError("result validity gates do not match the observed facts") + if result["status"] == "valid" and not all(expected_gates.values()): + failed = ", ".join(name for name, passed in expected_gates.items() if not passed) + raise BenchmarkError(f"valid result has failed scene gates: {failed}") + missing_context = sorted(REQUIRED_CONTEXT_FIELDS - result["context"].keys()) + if missing_context: + raise BenchmarkError(f"result context is missing: {', '.join(missing_context)}") + effective_settings = result["context"].get("effective_settings") + target_scale = effective_settings.get("RenderBenchmarkUIScale") if isinstance(effective_settings, Mapping) else None + display_mismatches = display_contract_mismatches(result["context"], target_scale) + if not isinstance(effective_settings, Mapping) or effective_settings.get("RenderHiDPI") is not True: + display_mismatches.append("effective RenderHiDPI must be true") + if display_mismatches: + raise BenchmarkError("result display contract mismatch: " + "; ".join(display_mismatches)) + private_paths = find_private_paths(result) + if private_paths: + raise BenchmarkError(f"result contains private fields: {', '.join(private_paths)}") + summarize_result(result) + + +def validate_result_against_manifest( + result: Mapping[str, Any], + manifest: Mapping[str, Any], + require_all_gates: bool = True, +) -> None: + context = result.get("context") + requested_settings = manifest.get("settings") + if not isinstance(context, Mapping) or not isinstance(requested_settings, Mapping): + raise BenchmarkError("result or manifest has no settings context") + effective_settings = context.get("effective_settings") + if not isinstance(effective_settings, Mapping): + raise BenchmarkError("result context has no effective settings") + + mismatches = [ + f"{name}={effective_settings.get(name)!r}, requested {requested!r}" + for name, requested in requested_settings.items() + if effective_settings.get(name) != requested + ] + run = result.get("run", {}) + if not isinstance(run, Mapping): + mismatches.append("result run is not an object") + else: + expected_manifest_hash = canonical_hash(manifest) + expected_settings_hash = canonical_hash(requested_settings) + if run.get("manifest_hash") != expected_manifest_hash: + mismatches.append( + f"manifest_hash={run.get('manifest_hash')!r}, requested {expected_manifest_hash!r}" + ) + if run.get("settings_hash") != expected_settings_hash: + mismatches.append( + f"settings_hash={run.get('settings_hash')!r}, requested {expected_settings_hash!r}" + ) + validity = result.get("validity") + if not isinstance(validity, Mapping): + mismatches.append("result validity is not an object") + else: + requested_policy = manifest.get("validity") + if validity.get("policy") != requested_policy: + mismatches.append("scene validity policy differs from the manifest") + if validity.get("policy_hash") != canonical_hash(requested_policy): + mismatches.append("scene validity policy hash differs from the manifest") + gates = validity_gate_results( + validity.get("workload_id"), + validity.get("operator", {}), + validity.get("observed", {}), + requested_policy, + ) + if validity.get("gates") != gates: + mismatches.append("scene validity gates do not match the manifest policy") + elif require_all_gates and not all(gates.values()): + mismatches.append("scene validity gates do not pass the manifest policy") + if requested_settings.get("WindowMaximized") is False: + for context_name, setting_name in ( + ("width", "WindowWidth"), + ("height", "WindowHeight"), + ("backing_width", "WindowWidth"), + ("backing_height", "WindowHeight"), + ): + requested = requested_settings.get(setting_name) + if requested is not None and context.get(context_name) != requested: + mismatches.append( + f"actual {context_name}={context.get(context_name)!r}, requested {requested!r}" + ) + + mismatches.extend(display_contract_mismatches(context, requested_settings.get("RenderBenchmarkUIScale"))) + if mismatches: + raise BenchmarkError("result does not match manifest: " + "; ".join(mismatches)) + + +def _nested(result: Mapping[str, Any], path: Sequence[str]) -> Any: + value: Any = result + for key in path: + if not isinstance(value, Mapping) or key not in value: + return None + value = value[key] + return value + + +def comparison_mismatches(results: Sequence[Mapping[str, Any]]) -> dict[str, list[Any]]: + if len(results) < 2: + return {} + mismatches: dict[str, list[Any]] = {} + for path in COMPARISON_FIELDS: + values = [_nested(result, path) for result in results] + if any(value != values[0] for value in values[1:]): + mismatches[".".join(path)] = values + + # Native OpenGL and Zink expose different extension sets by design. Require + # extension stability between repeats of one backend, but retain the hashes + # in reports so cross-backend capability differences remain auditable. + backend_indices: dict[str, list[int]] = {} + for index, result in enumerate(results): + backend = str(_nested(result, ("context", "backend_label"))) + backend_indices.setdefault(backend, []).append(index) + for backend, indices in backend_indices.items(): + values = [_nested(results[index], ("context", "gl_extensions_hash")) for index in indices] + if any(value != values[0] for value in values[1:]): + mismatches[f"context.gl_extensions_hash[{backend}]"] = values + return mismatches + + +def _read_credentials(path: Path) -> tuple[str, str, str]: + try: + if os.name != "nt" and stat.S_IMODE(path.stat().st_mode) & 0o077: + raise BenchmarkError("credential file must not be accessible by group or other users") + lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines()] + except OSError as error: + raise BenchmarkError(f"cannot read credential file: {error}") from error + lines = [line for line in lines if line and not line.startswith("#")] + if len(lines) != 1: + raise BenchmarkError("credential file must contain exactly one non-comment account") + fields = lines[0].split() + if len(fields) == 2: + return fields[0], "Resident", fields[1] + if len(fields) == 3: + return fields[0], fields[1], fields[2] + raise BenchmarkError("credential line must be: username password, or first last password") + + +def _settings_xml(settings: Mapping[str, Any], destination: Path) -> None: + root = ET.Element("llsd") + root_map = ET.SubElement(root, "map") + for name, value in sorted(settings.items()): + ET.SubElement(root_map, "key").text = str(name) + setting_map = ET.SubElement(root_map, "map") + ET.SubElement(setting_map, "key").text = "Value" + if isinstance(value, bool): + ET.SubElement(setting_map, "integer").text = "1" if value else "0" + elif isinstance(value, int): + ET.SubElement(setting_map, "integer").text = str(value) + elif isinstance(value, float): + ET.SubElement(setting_map, "real").text = repr(value) + elif isinstance(value, str): + ET.SubElement(setting_map, "string").text = value + else: + raise BenchmarkError(f"unsupported setting value for {name}: {value!r}") + ET.ElementTree(root).write(destination, encoding="utf-8", xml_declaration=True) + + +def _git_source(repo: Path) -> dict[str, Any]: + commit = subprocess.run( + ["git", "rev-parse", "HEAD"], + cwd=repo, + check=True, + capture_output=True, + text=True, + ) + tracked_diff = subprocess.run( + ["git", "diff", "--binary", "HEAD", "--", "."], + cwd=repo, + check=True, + capture_output=True, + ).stdout + return { + "git_commit": commit.stdout.strip(), + "git_dirty": bool(tracked_diff), + "git_diff_hash": hashlib.sha256(tracked_diff).hexdigest(), + } + + +def _redacted_command(command: Sequence[str], private_indices: Iterable[int]) -> str: + redacted = list(command) + for index in private_indices: + redacted[index] = "" + return shlex.join(redacted) + + +def benchmark_run_numbers( + cache_mode: str, + repeats: int, + warm_prime_attempts: int = 1, + prime_only: bool = False, +) -> list[int]: + """Return measured repeats, optionally preceded by bounded warm-cache primes.""" + measured = list(range(1, repeats + 1)) + if cache_mode != "warm": + return measured + return [0] * warm_prime_attempts + ([] if prime_only else measured) + + +def failed_validity_gate_names(result: Any) -> list[str]: + """Extract only known failed gate names for a privacy-safe rejection message.""" + if not isinstance(result, Mapping): + return [] + validity = result.get("validity") + gates = validity.get("gates") if isinstance(validity, Mapping) else None + if not isinstance(gates, Mapping): + return [] + return [name for name in VALIDITY_GATE_NAMES if gates.get(name) is False] + + +def benchmark_state_settings( + manifest_settings: Mapping[str, Any], + state: Path, + cache_mode: str, + initialize: bool = True, +) -> dict[str, Any]: + """Create the disposable state roots and return a complete cache selection.""" + user_dir = state / "user" + cache_dir = state / "cache" + if initialize: + user_dir.mkdir(parents=True, exist_ok=True) + cache_dir.mkdir(parents=True, exist_ok=True) + + settings = dict(manifest_settings) + settings.update(OPERATIONAL_SETTINGS) + settings["PurgeCacheOnStartup"] = cache_mode == "cold" + settings["CacheLocation"] = str(cache_dir) + settings["NewCacheLocation"] = str(cache_dir) + return settings + + +def _cache_probe(directory: Path) -> str: + """Exercise create, write, rename, read, and cleanup without exposing a path.""" + source = directory / ".renderer-benchmark-cache-probe" + renamed = directory / ".renderer-benchmark-cache-probe-renamed" + if not directory.is_dir(): + return "root-missing" + cleanup_failed = False + for entry in (source, renamed): + try: + entry.unlink(missing_ok=True) + except OSError: + cleanup_failed = True + if cleanup_failed: + return "cleanup-failed" + try: + source.write_bytes(b"renderer-cache-probe") + except OSError: + return "write-failed" + + status = "ready" + try: + source.replace(renamed) + except OSError: + status = "rename-failed" + else: + try: + if renamed.read_bytes() != b"renderer-cache-probe": + status = "readback-failed" + except OSError: + status = "readback-failed" + + cleanup_failed = False + for entry in (source, renamed): + try: + entry.unlink(missing_ok=True) + except OSError: + cleanup_failed = True + return "cleanup-failed" if status == "ready" and cleanup_failed else status + + +def _file_count(directory: Path) -> int: + try: + return sum(1 for entry in directory.rglob("*") if entry.is_file()) + except OSError: + return -1 + + +def _safe_finite_number(value: Any) -> int | float | None: + if not _is_number(value): + return None + try: + return value if math.isfinite(value) else None + except OverflowError: + return None + + +def _cache_sentinel_status(state: Path) -> str: + sentinel = state / "cache" / "cache" / CACHE_SENTINEL_NAME + try: + return "ready" if sentinel.read_bytes() == CACHE_SENTINEL_CONTENT else "content-mismatch" + except FileNotFoundError: + return "missing" + except OSError: + return "read-failed" + + +def _install_cache_sentinel(state: Path) -> str: + sentinel = state / "cache" / "cache" / CACHE_SENTINEL_NAME + if not sentinel.parent.is_dir(): + return "asset-root-missing" + try: + sentinel.write_bytes(CACHE_SENTINEL_CONTENT) + except OSError: + return "write-failed" + return _cache_sentinel_status(state) + + +def cache_lifecycle_facts(state: Path) -> dict[str, Any]: + """Return aggregate facts for the requested and fallback cache roots.""" + requested = state / "cache" + fallback = state / "user" / "cache" + asset_dir_name = "cache" + return { + "requested_root_present": requested.is_dir(), + "requested_write_probe": _cache_probe(requested), + "requested_asset_root_present": (requested / asset_dir_name).is_dir(), + "requested_asset_write_probe": _cache_probe(requested / asset_dir_name), + "requested_texture_root_present": (requested / "texturecache").is_dir(), + "requested_object_root_present": (requested / "objectcache").is_dir(), + "requested_sentinel": _cache_sentinel_status(state), + "requested_file_count": _file_count(requested), + "fallback_root_present": fallback.is_dir(), + "fallback_asset_root_present": (fallback / asset_dir_name).is_dir(), + "fallback_file_count": _file_count(fallback), + } + + +def _viewer_log_cursor(log_root: Path) -> tuple[int, int, int] | None: + """Capture an internal identity and byte offset for the active viewer log.""" + try: + status = (log_root / "SecondLife.log").stat() + except OSError: + return None + return status.st_dev, status.st_ino, status.st_size + + +def _first_log_category( + log_root: Path, + signatures: Sequence[tuple[str, str]], + cursor: tuple[int, int, int] | None = None, +) -> str | None: + """Reduce new bytes from the active viewer log to one known category.""" + viewer_log = log_root / "SecondLife.log" + try: + status = viewer_log.stat() + except OSError: + return None + offset = 0 + if cursor is not None: + device, inode, previous_size = cursor + if (status.st_dev, status.st_ino) == (device, inode) and status.st_size >= previous_size: + offset = previous_size + try: + with viewer_log.open("rb") as stream: + stream.seek(offset) + for raw_line in stream: + line = raw_line.decode("utf-8", errors="replace") + for signature, category in signatures: + if signature in line: + return category + except OSError: + return None + return None + + +def readiness_attempt( + attempt: int, + outcome: str, + result: Any, + cache_before: Mapping[str, Any], + state: Path, + log_cursor: tuple[int, int, int] | None = None, + prepare_reuse: bool = False, +) -> dict[str, Any]: + """Reduce one prime to privacy-safe readiness facts with no frame timing.""" + validity = result.get("validity", {}) if isinstance(result, Mapping) else {} + observed = validity.get("observed", {}) if isinstance(validity, Mapping) else {} + if not isinstance(observed, Mapping): + observed = {} + gates = validity.get("gates", {}) if isinstance(validity, Mapping) else {} + if not isinstance(gates, Mapping): + gates = {} + policy = validity.get("policy", {}) if isinstance(validity, Mapping) else {} + if not isinstance(policy, Mapping): + policy = {} + self_avatar_loaded = observed.get("self_avatar_loaded") is True + agent_stationary = ( + _is_number(observed.get("agent_travel_m")) + and _is_number(policy.get("max_agent_travel_m")) + and observed["agent_travel_m"] <= policy["max_agent_travel_m"] + ) + settlement_complete = ( + _is_number(observed.get("settle_seconds_observed")) + and _is_number(policy.get("settle_seconds")) + and observed["settle_seconds_observed"] >= policy["settle_seconds"] + ) + asset_queues_settled = all( + _is_number(observed.get(field)) and observed[field] == 0 + for field in READINESS_ASSET_FIELDS + ) + log_avatar_blocker = _first_log_category( + state / "user" / "logs", AVATAR_BLOCKER_SIGNATURES, log_cursor + ) + appearance = ( + safe_appearance_attribution(observed["appearance"]) + if "appearance" in observed + else None + ) + appearance_classification = appearance["classification"] if appearance else "unknown" + avatar_blocker = ( + "none" + if self_avatar_loaded and agent_stationary + else "avatar-moved" + if self_avatar_loaded + else appearance_classification + if appearance_classification not in {"ready", "unknown"} + else log_avatar_blocker or "unknown" + ) + attempt_record = sanitize({ + "attempt": attempt, + "outcome": outcome, + "failed_gates": failed_validity_gate_names(result), + "target_gates": { + name: gates.get(name) if isinstance(gates.get(name), bool) else None + for name in READINESS_TARGET_GATES + }, + "all_scene_gates_passed": all(gates.get(name) is True for name in VALIDITY_GATE_NAMES), + "cache_before_launch": dict(cache_before), + "cache_after_launch": cache_lifecycle_facts(state), + "first_cache_failure": _first_log_category( + state / "user" / "logs", CACHE_FAILURE_SIGNATURES, log_cursor + ) or "none", + "assets": { + "settlement_complete": settlement_complete, + "queues_settled": asset_queues_settled, + "observed": { + field: _safe_finite_number(observed.get(field)) + for field in READINESS_ASSET_FIELDS + if field in observed + }, + }, + "avatar": { + "self_avatar_loaded": self_avatar_loaded, + "stationary": agent_stationary, + "blocker": avatar_blocker, + **({"appearance": appearance} if appearance else {}), + }, + }) + before = attempt_record["cache_before_launch"] + after = attempt_record["cache_after_launch"] + cache_failures: list[str] = [] + if before.get("requested_root_present") is not True: + cache_failures.append("requested-root-missing-before") + if before.get("requested_write_probe") != "ready": + cache_failures.append("write-probe-failed-before") + if attempt > 1 and before.get("requested_asset_root_present") is not True: + cache_failures.append("asset-root-missing-before-reuse") + if attempt > 1 and before.get("requested_asset_write_probe") != "ready": + cache_failures.append("asset-write-probe-failed-before-reuse") + if attempt > 1 and before.get("requested_sentinel") != "ready": + cache_failures.append("sentinel-missing-before-reuse") + if attempt > 1 and before.get("fallback_asset_root_present") is True: + cache_failures.append("fallback-asset-root-present-before-reuse") + if after.get("requested_root_present") is not True: + cache_failures.append("requested-root-missing-after") + if after.get("requested_write_probe") != "ready": + cache_failures.append("write-probe-failed-after") + if after.get("requested_asset_root_present") is not True: + cache_failures.append("asset-root-missing-after") + if after.get("requested_asset_write_probe") != "ready": + cache_failures.append("asset-write-probe-failed-after") + if attempt > 1 and after.get("requested_sentinel") != "ready": + cache_failures.append("sentinel-missing-after-reuse") + if after.get("fallback_asset_root_present") is True: + cache_failures.append("fallback-asset-root-present-after") + if attempt_record["first_cache_failure"] != "none": + cache_failures.append("cache-log-failure") + if prepare_reuse: + sentinel_install = _install_cache_sentinel(state) + attempt_record["sentinel_install"] = sentinel_install + if sentinel_install != "ready": + cache_failures.append("sentinel-install-failed") + attempt_record["cache_failures"] = cache_failures + attempt_record["cache_ready"] = not cache_failures + return attempt_record + + +def write_readiness_report(path: Path, attempts: Sequence[Mapping[str, Any]]) -> None: + """Write a no-timing diagnostic artifact after a bounded prime-only run.""" + last_attempt = attempts[-1] if attempts else {} + last_targets = last_attempt.get("target_gates", {}) + readiness_passed = isinstance(last_targets, Mapping) and all( + last_targets.get(name) is True for name in READINESS_TARGET_GATES + ) + cache_reuse_passed = len(attempts) >= 2 and all( + attempt.get("cache_ready") is True for attempt in attempts + ) + report = sanitize({ + "schema_version": 1, + "kind": "renderer-readiness", + "scene_contract": "schema-3-gates-unchanged", + "target_gates": list(READINESS_TARGET_GATES), + "readiness_passed": bool(attempts) + and last_attempt.get("outcome") == "readiness-passed" + and cache_reuse_passed + and readiness_passed, + "cache_reuse_passed": cache_reuse_passed, + "all_scene_gates_passed": bool(attempts) + and last_attempt.get("all_scene_gates_passed") is True, + "valid_measured_repeats": 0, + "retained_timing": False, + "attempts": list(attempts), + }) + private = find_private_paths(report) + if private: + raise BenchmarkError("readiness report contains a private field") + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(report, indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def _run_command(args: argparse.Namespace) -> int: + manifest_path = Path(args.manifest).resolve() + manifest = load_json(manifest_path) + if not isinstance(manifest, Mapping): + raise BenchmarkError("manifest root must be an object") + validate_manifest(manifest) + + operator_state = { + "power_source": args.power_source, + "low_power_mode": args.low_power_mode, + "thermal_state": args.thermal_state, + "scene_events": args.scene_events, + "ui_state": args.ui_state, + "camera_state": args.camera_state, + } + validate_operator_state(args.workload_id, operator_state) + + viewer = Path(args.viewer).resolve() + plugin = Path(__file__).with_name("render_benchmark_leap.py").resolve() + repo = Path(__file__).resolve().parents[2] + output_dir = Path(args.output_dir).resolve() + capture = manifest["capture"] + repeats = args.repeats if args.repeats is not None else int(capture["repeats"]) + if repeats < 1: + raise BenchmarkError("repeats must be positive") + if args.startup_timeout < 1: + raise BenchmarkError("startup timeout must be positive") + if args.warm_prime_attempts < 1: + raise BenchmarkError("warm prime attempts must be positive") + if manifest["cache_mode"] != "warm" and (args.prime_only or args.warm_prime_attempts != 1): + raise BenchmarkError("prime-only controls require a warm-cache manifest") + if args.warm_prime_attempts != 1 and not args.prime_only: + raise BenchmarkError("multiple warm primes require --prime-only") + if args.readiness_output and not args.prime_only: + raise BenchmarkError("readiness output requires --prime-only") + if args.readiness_output and args.warm_prime_attempts < 2: + raise BenchmarkError("readiness output requires at least two warm primes") + output_dir.mkdir(parents=True, exist_ok=True) + readiness_output = Path(args.readiness_output).resolve() if args.readiness_output else None + backend = args.backend + settings_hash = canonical_hash(manifest["settings"]) + manifest_hash = canonical_hash(manifest) + + if args.dry_run: + first, last, password = "", "", "" + else: + first, last, password = _read_credentials(Path(args.credential_file)) + + with tempfile.TemporaryDirectory(prefix="sl-render-benchmark-") as batch_name: + batch = Path(batch_name) + warm_state = batch / "warm-state" + initialized_states: set[Path] = set() + readiness_attempts: list[dict[str, Any]] = [] + prime_attempt = 0 + run_numbers = benchmark_run_numbers( + str(manifest["cache_mode"]), + repeats, + args.warm_prime_attempts, + args.prime_only, + ) + for run_number in run_numbers: + is_prime = run_number == 0 + if is_prime: + prime_attempt += 1 + run_label = ( + f"warm-cache prime {prime_attempt}/{args.warm_prime_attempts}" + if is_prime and args.warm_prime_attempts > 1 + else "warm-cache prime" + if is_prime + else f"run {run_number}" + ) + temp = batch / ( + f"prime-{prime_attempt:02d}" if is_prime else f"run-{run_number:02d}" + ) + temp.mkdir() + output = ( + temp / "warm-prime.json" + if is_prime + else output_dir / f"{manifest['id']}-{backend}-run-{run_number:02d}.json" + ) + state = warm_state if manifest["cache_mode"] == "warm" else temp + settings = benchmark_state_settings( + manifest["settings"], + state, + manifest["cache_mode"], + initialize=state not in initialized_states, + ) + initialized_states.add(state) + cache_before = cache_lifecycle_facts(state) if args.prime_only else {} + log_cursor = ( + _viewer_log_cursor(state / "user" / "logs") if args.prime_only else None + ) + settings_file = temp / "session.xml" + _settings_xml(settings, settings_file) + + plugin_config = { + "schema_version": SCHEMA_VERSION, + "output": str(output), + "appearance_diagnostics": bool(is_prime and args.prime_only), + "run": { + "scenario": manifest["id"], + # The prime is a complete, schema-valid capture whose + # artifact is discarded. It may share run 1's ordinal + # because it can never enter a report. + "run_number": 1 if is_prime else run_number, + "cache_mode": manifest["cache_mode"], + "manifest_hash": manifest_hash, + "settings_hash": settings_hash, + "warmup_seconds": capture["warmup_seconds"], + "duration_seconds": capture["duration_seconds"], + "poll_interval_seconds": capture["poll_interval_seconds"], + }, + "context": { + "backend_label": backend, + "hardware_label": args.hardware_label, + **_git_source(repo), + }, + "validity": { + "workload_id": args.workload_id, + "operator": operator_state, + "policy": manifest["validity"], + }, + "requested_settings": settings, + "startup_timeout_seconds": args.startup_timeout, + "expected_gpu_substring": args.expect_gpu_substring, + } + plugin_config_path = temp / "plugin-config.json" + plugin_config_path.write_text(json.dumps(plugin_config), encoding="utf-8") + leap_command = shlex.join([sys.executable, str(plugin), "--config", str(plugin_config_path)]) + command = [ + str(viewer), + *OPERATIONAL_SWITCHES, + "--usersessionsettings", + str(settings_file), + "--leap", + llsd_string_notation(leap_command), + "--login", + first, + last, + password, + "--slurl", + args.slurl, + ] + login_index = command.index("--login") + slurl_index = command.index("--slurl") + print(_redacted_command(command, (*range(login_index + 1, login_index + 4), slurl_index + 1))) + if args.dry_run: + continue + + environment = os.environ.copy() + environment["SECONDLIFE_USER_DIR"] = str(state / "user") + if backend == "zink": + environment["MESA_LOADER_DRIVER_OVERRIDE"] = "zink" + timeout = args.startup_timeout + float(capture["warmup_seconds"]) + float(capture["duration_seconds"]) + 60 + try: + completed = subprocess.run( + command, + cwd=args.viewer_cwd or viewer.parent, + env=environment, + timeout=timeout, + check=False, + ) + except subprocess.TimeoutExpired as error: + if is_prime and args.prime_only: + readiness_attempts.append( + readiness_attempt( + prime_attempt, "timeout", {}, cache_before, state, log_cursor + ) + ) + if readiness_output: + write_readiness_report(readiness_output, readiness_attempts) + raise BenchmarkError(f"viewer timed out during {run_label}") from error + if not output.exists(): + if is_prime and args.prime_only: + readiness_attempts.append( + readiness_attempt( + prime_attempt, "no-artifact", {}, cache_before, state, log_cursor + ) + ) + if readiness_output: + write_readiness_report(readiness_output, readiness_attempts) + raise BenchmarkError(f"viewer produced no artifact for {run_label}") + try: + result = load_json(output) + except BenchmarkError: + if is_prime and args.prime_only: + readiness_attempts.append( + readiness_attempt( + prime_attempt, + "invalid-artifact", + {}, + cache_before, + state, + log_cursor, + ) + ) + if readiness_output: + write_readiness_report(readiness_output, readiness_attempts) + raise + failed_gates = failed_validity_gate_names(result) + if completed.returncode != 0: + if is_prime and args.prime_only: + readiness_attempts.append( + readiness_attempt( + prime_attempt, + "viewer-error", + result, + cache_before, + state, + log_cursor, + ) + ) + if readiness_output: + write_readiness_report(readiness_output, readiness_attempts) + raise BenchmarkError(f"viewer exited with {completed.returncode} during {run_label}") + try: + validate_result(result, require_valid=False) + validate_result_against_manifest(result, manifest, require_all_gates=False) + except BenchmarkError: + if is_prime and args.prime_only: + readiness_attempts.append( + readiness_attempt( + prime_attempt, + "invalid-artifact", + result, + cache_before, + state, + log_cursor, + ) + ) + if readiness_output: + write_readiness_report(readiness_output, readiness_attempts) + raise + if result.get("status") == "invalid": + expected_reason = f"scene validity gates failed: {', '.join(failed_gates)}" + if result.get("failure_reason") != expected_reason: + if is_prime and args.prime_only: + readiness_attempts.append( + readiness_attempt( + prime_attempt, + "invalid-run", + result, + cache_before, + state, + log_cursor, + ) + ) + if readiness_output: + write_readiness_report(readiness_output, readiness_attempts) + raise BenchmarkError(f"{run_label} failed outside the scene validity gates") + gates = result["validity"]["gates"] + gates_to_retry = ( + [name for name in READINESS_TARGET_GATES if gates.get(name) is not True] + if args.prime_only + else failed_gates + ) + if gates_to_retry: + gate_family = "readiness gates" if args.prime_only else "scene validity gates" + if is_prime and args.prime_only: + readiness_attempts.append( + readiness_attempt( + prime_attempt, + "rejected", + result, + cache_before, + state, + log_cursor, + prepare_reuse=prime_attempt == 1 + and prime_attempt < args.warm_prime_attempts, + ) + ) + if is_prime and prime_attempt < args.warm_prime_attempts: + print( + f"{run_label} rejected by {gate_family}: {', '.join(gates_to_retry)}; " + "retrying with the same disposable warm cache" + ) + continue + if readiness_output: + write_readiness_report(readiness_output, readiness_attempts) + raise BenchmarkError( + f"{run_label} rejected by {gate_family}: {', '.join(gates_to_retry)}" + ) + if not args.prime_only: + validate_result(result) + validate_result_against_manifest(result, manifest) + if is_prime and args.prime_only: + attempt_record = readiness_attempt( + prime_attempt, + "readiness-passed", + result, + cache_before, + state, + log_cursor, + prepare_reuse=prime_attempt == 1 + and prime_attempt < args.warm_prime_attempts, + ) + if not attempt_record["cache_ready"]: + attempt_record["outcome"] = "cache-rejected" + readiness_attempts.append(attempt_record) + if prime_attempt < args.warm_prime_attempts: + status = ( + "passed the readiness gates" + if attempt_record["cache_ready"] + else "failed the cache lifecycle check" + ) + print( + f"{run_label} {status}; " + "validating the same disposable warm cache again" + ) + continue + cache_lifecycle_passed = bool(readiness_attempts) and all( + attempt.get("cache_ready") is True for attempt in readiness_attempts + ) + if not cache_lifecycle_passed: + attempt_record["outcome"] = "cache-rejected" + if readiness_output: + write_readiness_report(readiness_output, readiness_attempts) + raise BenchmarkError(f"{run_label} rejected by the cache lifecycle check") + if readiness_output: + write_readiness_report(readiness_output, readiness_attempts) + return 0 + if not is_prime: + output.write_text( + json.dumps(sanitize(result), indent=2, sort_keys=True) + "\n", + encoding="utf-8", + ) + return 0 + + +def _format_markdown(results: Sequence[Mapping[str, Any]], override_note: str | None) -> str: + rows = [] + backend_p95: dict[str, list[float]] = {} + for result in results: + summary = summarize_result(result) + p95 = summary["frame_time_ms"]["p95"] + backend = str(result["context"]["backend_label"]) + backend_p95.setdefault(backend, []).append(p95) + rows.append( + "| {run} | {backend} | {samples} | {median:.2f} | {p95:.2f} | {p99:.2f} | {worst:.2f} | {low:.1f} |".format( + run=result["run"]["run_number"], + backend=result["context"]["backend_label"], + samples=summary["sample_count"], + median=summary["frame_time_ms"]["median"], + p95=p95, + p99=summary["frame_time_ms"]["p99"], + worst=summary["frame_time_ms"]["worst"], + low=summary["one_percent_low_fps"], + ) + ) + lines = [ + f"# Renderer benchmark: {results[0]['run']['scenario']}", + "", + "| Run | Backend | Frames | Median ms | p95 ms | p99 ms | Worst ms | 1% low FPS |", + "|---:|---|---:|---:|---:|---:|---:|---:|", + *rows, + "", + "| Backend | Repeats | Median run p95 ms | Run-to-run p95 range ms | Extension hash |", + "|---|---:|---:|---:|---|", + ] + backend_medians: dict[str, float] = {} + backend_ranges: dict[str, float] = {} + for backend, values in sorted(backend_p95.items()): + median_p95 = percentile(values, 0.5) + p95_range = max(values) - min(values) + backend_medians[backend] = median_p95 + backend_ranges[backend] = p95_range + extension_hash = next( + str(result["context"].get("gl_extensions_hash", "missing")) + for result in results + if result["context"]["backend_label"] == backend + ) + lines.append( + f"| {backend} | {len(values)} | {median_p95:.2f} | {p95_range:.2f} | `{extension_hash[:12]}` |" + ) + + if {"native-gl", "zink"}.issubset(backend_medians): + delta = backend_medians["zink"] - backend_medians["native-gl"] + noise = max(backend_ranges["native-gl"], backend_ranges["zink"]) + direction = "slower" if delta > 0 else "faster" + repeat_count = min(len(backend_p95["native-gl"]), len(backend_p95["zink"])) + if repeat_count < MIN_COMPARISON_REPEATS: + verdict = ( + f"Decision is indeterminate: {repeat_count} matched repeat(s) were supplied, " + f"but {MIN_COMPARISON_REPEATS} per backend are required." + ) + else: + meaningful = abs(delta) > 1.0 and abs(delta) > 3.0 * noise + threshold = "meaningful" if meaningful else "below the decision threshold" + verdict = f"This result is {threshold} (>1 ms and >3x noise required)." + lines.extend([ + "", + ( + f"Zink median run p95 is {abs(delta):.2f} ms {direction} than native GL; " + f"the largest within-backend p95 range is {noise:.2f} ms. {verdict}" + ), + ]) + if override_note: + lines.extend(["", f"Comparison guard overridden: {override_note}"]) + return "\n".join(lines) + "\n" + + +def _format_csv(results: Sequence[Mapping[str, Any]]) -> str: + from io import StringIO + + stream = StringIO() + writer = csv.writer(stream, lineterminator="\n") + writer.writerow(("scenario", "run", "backend", "frames", "median_ms", "p95_ms", "p99_ms", "worst_ms", "one_percent_low_fps")) + for result in results: + summary = summarize_result(result) + writer.writerow(( + result["run"]["scenario"], + result["run"]["run_number"], + result["context"]["backend_label"], + summary["sample_count"], + summary["frame_time_ms"]["median"], + summary["frame_time_ms"]["p95"], + summary["frame_time_ms"]["p99"], + summary["frame_time_ms"]["worst"], + summary["one_percent_low_fps"], + )) + return stream.getvalue() + + +def _report_command(args: argparse.Namespace) -> int: + results = [] + for raw_path in args.results: + result = load_json(Path(raw_path)) + if not isinstance(result, Mapping): + raise BenchmarkError(f"result root must be an object: {raw_path}") + validate_result(result) + results.append(result) + if not results: + raise BenchmarkError("at least one result is required") + mismatches = comparison_mismatches(results) + override_note = None + if mismatches: + detail = "; ".join(f"{key}={values!r}" for key, values in mismatches.items()) + if not args.allow_mismatch: + raise BenchmarkError(f"comparison context mismatch: {detail}") + override_note = detail + rendered = _format_markdown(results, override_note) if args.format == "markdown" else _format_csv(results) + if args.output == "-": + sys.stdout.write(rendered) + else: + Path(args.output).write_text(rendered, encoding="utf-8") + return 0 + + +def _validate_command(args: argparse.Namespace) -> int: + value = load_json(Path(args.path)) + if not isinstance(value, Mapping): + raise BenchmarkError("JSON root must be an object") + if args.kind == "manifest": + validate_manifest(value) + else: + validate_result(value, require_valid=not args.allow_invalid) + print(f"valid {args.kind}: {args.path}") + return 0 + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False) + subparsers = parser.add_subparsers(dest="command", required=True) + + run = subparsers.add_parser("run", help="run a viewer benchmark") + run.add_argument("--viewer", required=True) + run.add_argument("--viewer-cwd") + run.add_argument("--manifest", required=True) + run.add_argument("--credential-file", required=True) + run.add_argument("--slurl", required=True, help="operator-supplied location; omitted from results") + run.add_argument("--hardware-label", required=True, help="non-identifying operator label") + run.add_argument("--workload-id", required=True, help="privacy-safe controlled-workload slug") + run.add_argument("--power-source", required=True, choices=("ac", "battery", "unknown")) + run.add_argument("--low-power-mode", required=True, choices=("off", "on", "unknown")) + run.add_argument( + "--thermal-state", required=True, choices=("nominal", "elevated", "throttled", "unknown") + ) + run.add_argument("--scene-events", required=True, choices=("none", "observed", "unknown")) + run.add_argument("--ui-state", required=True, choices=("approved", "unapproved", "unknown")) + run.add_argument("--camera-state", required=True, choices=("approved", "unapproved", "unknown")) + run.add_argument("--expect-gpu-substring", help="invalidate a run if a different GPU is selected") + run.add_argument("--backend", choices=("native-gl", "zink"), default="native-gl") + run.add_argument("--repeats", type=int) + run.add_argument( + "--warm-prime-attempts", + type=int, + default=1, + help="number of unmeasured prime-only launches sharing one warm cache", + ) + run.add_argument( + "--prime-only", + action="store_true", + help="stop after one schema-valid warm prime; never run a measured repeat", + ) + run.add_argument( + "--readiness-output", + help="write privacy-safe no-timing facts for a prime-only investigation", + ) + run.add_argument("--startup-timeout", type=int, default=DEFAULT_STARTUP_TIMEOUT_SECONDS) + run.add_argument("--output-dir", required=True) + run.add_argument("--dry-run", action="store_true") + run.set_defaults(handler=_run_command) + + report = subparsers.add_parser("report", help="validate and summarize raw results") + report.add_argument("results", nargs="+") + report.add_argument("--format", choices=("markdown", "csv"), default="markdown") + report.add_argument("--output", default="-") + report.add_argument("--allow-mismatch", action="store_true") + report.set_defaults(handler=_report_command) + + validate = subparsers.add_parser("validate", help="validate a manifest or result") + validate.add_argument("kind", choices=("manifest", "result")) + validate.add_argument("path") + validate.add_argument("--allow-invalid", action="store_true") + validate.set_defaults(handler=_validate_command) + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + parser = build_parser() + args = parser.parse_args(argv) + try: + return args.handler(args) + except (BenchmarkError, OSError, subprocess.CalledProcessError) as error: + parser.error(str(error)) + return 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/perf/render_benchmark_leap.py b/scripts/perf/render_benchmark_leap.py new file mode 100755 index 00000000000..4edc52d1a54 --- /dev/null +++ b/scripts/perf/render_benchmark_leap.py @@ -0,0 +1,536 @@ +#!/usr/bin/env python3 +"""LLLeap collector for render_benchmark.py; launched by the viewer.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path +import sys +import time +from typing import Any, Mapping, Sequence + +import llsd + +from render_benchmark import ( + SCHEMA_VERSION, + canonical_hash, + display_contract_mismatches, + safe_appearance_attribution, + sanitize, + summarize_result, + validity_gate_results, +) + + +class ProtocolError(RuntimeError): + pass + + +def read_packet() -> Any: + header = bytearray() + while b":" not in header and len(header) < 20: + chunk = sys.stdin.buffer.read(1) + if not chunk: + raise EOFError("viewer closed the LLLeap stream") + header.extend(chunk) + if not header.endswith(b":"): + raise ProtocolError(f"invalid LLLeap length header: {bytes(header)!r}") + try: + length = int(header[:-1]) + except ValueError as error: + raise ProtocolError(f"non-numeric LLLeap length: {bytes(header[:-1])!r}") from error + payload = sys.stdin.buffer.read(length) + if len(payload) != length: + raise EOFError(f"short LLLeap packet: expected {length}, received {len(payload)}") + return llsd.parse(payload) + + +def write_packet(pump: str, data: Mapping[str, Any]) -> None: + payload = llsd.format_notation({"pump": pump, "data": dict(data)}) + sys.stdout.buffer.write(str(len(payload)).encode("ascii") + b":" + payload) + sys.stdout.buffer.flush() + + +class ViewerAPI: + def __init__(self) -> None: + initial = read_packet() + self.reply_pump = initial["pump"] + self.request_id = 0 + + def request(self, pump: str, data: Mapping[str, Any]) -> Any: + self.request_id += 1 + request = dict(data) + request["reply"] = self.reply_pump + request["reqid"] = self.request_id + write_packet(pump, request) + response = read_packet() + if isinstance(response, Mapping) and "data" in response: + return response["data"] + return response + + def perf_data(self) -> Mapping[str, Any]: + response = self.request("LLStats", {"op": "getPerfData"}) + if not isinstance(response, Mapping) or not isinstance(response.get("stats"), Mapping): + raise ProtocolError("LLStats.getPerfData returned no stats map") + return response["stats"] + + def renderer_diagnostic_state(self) -> Mapping[str, Any]: + response = self.request("LLStats", {"op": "getRendererDiagnosticState"}) + if not isinstance(response, Mapping): + raise ProtocolError("LLStats.getRendererDiagnosticState returned no map") + return response + + def request_quit(self) -> None: + write_packet("LLAppViewer", {"op": "requestQuit"}) + + +def apply_requested_settings(settings: Mapping[str, Any], api: ViewerAPI) -> None: + for name, value in settings.items(): + response = api.request( + "LLViewerControl", + {"op": "set", "group": "Global", "key": name, "value": value}, + ) + if not isinstance(response, Mapping) or response.get("error"): + detail = response.get("error") if isinstance(response, Mapping) else "no response map" + raise ProtocolError(f"could not apply setting {name}: {detail}") + + +def normalize_renderer_display(api: ViewerAPI) -> None: + response = api.request("LLStats", {"op": "normalizeRendererDisplay"}) + if not isinstance(response, Mapping) or response.get("error") or not response.get("accepted"): + detail = response.get("error") if isinstance(response, Mapping) else "no response map" + raise ProtocolError(f"could not normalize renderer display: {detail}") + + +def wait_for_display_contract( + settings: Mapping[str, Any], + api: ViewerAPI, + timeout_seconds: float, + poll_interval: float, +) -> Mapping[str, Any]: + deadline = time.monotonic() + timeout_seconds + mismatches: list[str] = [] + while time.monotonic() < deadline: + stats = api.perf_data() + context = stats.get("renderer_context", {}) + if isinstance(context, Mapping): + mismatches = display_contract_mismatches( + context, settings.get("RenderBenchmarkUIScale") + ) + if not mismatches: + return stats + time.sleep(poll_interval) + raise ProtocolError("renderer display contract did not settle: " + "; ".join(mismatches)) + + +def _merge_frames(target: dict[int, dict[str, Any]], stats: Mapping[str, Any], after: int) -> None: + for raw_frame in stats.get("renderer_frames", []): + if not isinstance(raw_frame, Mapping) or not isinstance(raw_frame.get("frame_number"), (int, float)): + continue + frame_number = int(raw_frame["frame_number"]) + if frame_number > after: + target[frame_number] = dict(raw_frame) + + +def _latest_frame_number(stats: Mapping[str, Any]) -> int: + values = [ + int(frame["frame_number"]) + for frame in stats.get("renderer_frames", []) + if isinstance(frame, Mapping) and isinstance(frame.get("frame_number"), (int, float)) + ] + return max(values, default=0) + + +def _add_unclassified_time(frames: list[dict[str, Any]]) -> None: + exclusive_phases = ( + "geometry_create_ms", + "partition_ms", + "geometry_update_ms", + "cull_ms", + "shadows_ms", + "texture_work_ms", + "state_sort_ms", + "submission_ms", + "lighting_ms", + "ui_ms", + "swap_ms", + "idle_ms", + ) + for frame in frames: + frame_time = frame.get("frame_time_ms") + if not isinstance(frame_time, (int, float)): + continue + classified = sum(float(frame.get(field, 0.0)) for field in exclusive_phases) + frame["unclassified_ms"] = max(0.0, float(frame_time) - classified) + + +def _scene_state(stats: Mapping[str, Any]) -> dict[str, Any]: + state = stats.get("renderer_scene_state", {}) + return dict(state) if isinstance(state, Mapping) else {} + + +def sample_appearance_attribution( + api: ViewerAPI, +) -> tuple[dict[str, Any], dict[str, Any]]: + diagnostic = api.renderer_diagnostic_state() + scene_state = diagnostic.get("scene_state") + if not isinstance(scene_state, Mapping) or "appearance" not in diagnostic: + raise ProtocolError("diagnostic renderer response is incomplete") + scene = dict(scene_state) + appearance = safe_appearance_attribution(diagnostic["appearance"]) + if scene.get("self_avatar_loaded") is not appearance["avatar_loaded"]: + raise ProtocolError("appearance attribution does not match its scene observation") + return scene, appearance + + +def sample_scene_and_appearance( + stats: Mapping[str, Any], api: ViewerAPI, enabled: bool +) -> tuple[dict[str, Any], dict[str, Any] | None]: + if not enabled: + return _scene_state(stats), None + return sample_appearance_attribution(api) + + +def summarize_appearance_attribution( + scene_states: Sequence[Mapping[str, Any]], + observations: Sequence[Mapping[str, Any]], +) -> dict[str, Any] | None: + """Retain attribution for the last avatar failure in the guarded window.""" + if len(scene_states) != len(observations): + raise ProtocolError("appearance observations do not match scene observations") + projected = [safe_appearance_attribution(value) for value in observations] + for state, value in reversed(list(zip(scene_states, projected))): + if state.get("self_avatar_loaded") is not True: + return value + return projected[-1] if projected else None + + +def _numeric_values(states: Sequence[Mapping[str, Any]], field: str) -> list[float]: + return [ + float(state[field]) + for state in states + if isinstance(state.get(field), (int, float)) and not isinstance(state.get(field), bool) + ] + + +def _frame_total(frames: Sequence[Mapping[str, Any]], field: str) -> float: + return sum( + max(0.0, float(frame[field])) + for frame in frames + if isinstance(frame.get(field), (int, float)) and not isinstance(frame.get(field), bool) + ) + + +def _counter_delta(states: Sequence[Mapping[str, Any]], field: str) -> float: + values = _numeric_values(states, field) + return max(0.0, max(values) - min(values)) if values else -1.0 + + +def _maximum(states: Sequence[Mapping[str, Any]], field: str, default: float = -1.0) -> float: + values = _numeric_values(states, field) + return max(values, default=default) + + +def _minimum(states: Sequence[Mapping[str, Any]], field: str, default: float = -1.0) -> float: + values = _numeric_values(states, field) + return min(values, default=default) + + +def _rounded_fingerprint(value: Any) -> Any: + if isinstance(value, Mapping): + return {str(key): _rounded_fingerprint(item) for key, item in value.items()} + if isinstance(value, list): + return [_rounded_fingerprint(item) for item in value] + if isinstance(value, float): + return round(value, 4) + return value + + +def summarize_scene_validity( + policy: Mapping[str, Any], + settle_states: Sequence[Mapping[str, Any]], + capture_states: Sequence[Mapping[str, Any]], + frames: Sequence[Mapping[str, Any]], + settle_seconds_observed: float, +) -> dict[str, Any]: + """Reduce private live scene state to facts that are safe to retain.""" + guarded_states = [*settle_states, *capture_states] + first_state = capture_states[0] if capture_states else {} + view = first_state.get("view", {}) if isinstance(first_state, Mapping) else {} + + frame_counts = _numeric_values(capture_states, "frame_count_total") + foreground_counts = _numeric_values(capture_states, "foreground_frame_count_total") + background_frames = -1 + if frame_counts and foreground_counts: + background_frames = max( + 0, + round((max(frame_counts) - min(frame_counts)) - (max(foreground_counts) - min(foreground_counts))), + ) + if any(state.get("app_focused") is not True for state in capture_states): + background_frames = max(1, background_frames) + + agent_distances = _numeric_values(capture_states, "agent_distance_traveled_total") + visible_min = _minimum(capture_states, "visible_avatars") + visible_max = _maximum(capture_states, "visible_avatars") + active_min = _minimum(capture_states, "active_objects") + active_max = _maximum(capture_states, "active_objects") + ping_values = [ + float(frame["sim_ping_ms"]) + for frame in frames + if isinstance(frame.get("sim_ping_ms"), (int, float)) and not isinstance(frame.get("sim_ping_ms"), bool) + ] + + return { + "settle_seconds_observed": max(0.0, settle_seconds_observed), + "destination_ready": bool(guarded_states) + and all(state.get("destination_matches") is True for state in guarded_states), + "teleport_seen": any(state.get("teleport_in_progress") is True for state in guarded_states), + "progress_seen": any(state.get("progress_visible") is True for state in guarded_states), + "background_frame_count": background_frames, + "camera_animating_seen": any(state.get("camera_animating") is True for state in capture_states), + "camera_translation_m": _frame_total(frames, "camera_translation_m"), + "camera_rotation_rad": _frame_total(frames, "camera_rotation_rad"), + "agent_travel_m": ( + max(0.0, max(agent_distances) - min(agent_distances)) if agent_distances else -1.0 + ), + "agent_speed_mps_max": _maximum(capture_states, "agent_speed_mps"), + "view_hash": canonical_hash(_rounded_fingerprint(view)) if isinstance(view, Mapping) and view else "", + "modal_dialog_max": round(_maximum(guarded_states, "modal_dialog_count")), + "alert_toast_seen": any(state.get("alert_toast_visible") is True for state in guarded_states), + "welcome_pack_seen": any(state.get("welcome_pack_visible") is True for state in guarded_states), + "hint_seen": any(state.get("hint_visible") is True for state in guarded_states), + "closeable_floaters_closed": bool(guarded_states) + and all(state.get("closeable_floaters_closed") is True for state in guarded_states), + "texture_fetch_requests_max": round(_maximum(guarded_states, "texture_fetch_requests")), + "texture_http_requests_max": round(_maximum(guarded_states, "texture_http_requests")), + "texture_create_queue_max": round(_maximum(guarded_states, "texture_create_queue")), + "texture_fast_cache_max": round(_maximum(guarded_states, "texture_fast_cache")), + "texture_upload_count_delta": _counter_delta(guarded_states, "texture_upload_count_total"), + "mesh_lod_unresolved_max": round(_maximum(guarded_states, "mesh_lod_unresolved")), + "mesh_skin_unresolved_max": round(_maximum(guarded_states, "mesh_skin_unresolved")), + "self_avatar_loaded": bool(guarded_states) + and all(state.get("self_avatar_loaded") is True for state in guarded_states), + "visible_avatars_min": round(visible_min), + "visible_avatars_max": round(visible_max), + "active_objects_min": round(active_min), + "active_objects_max": round(active_max), + "new_objects_total": _frame_total(frames, "new_objects"), + "sim_ping_ms_max": max(ping_values, default=-1.0), + "circuit_healthy": bool(guarded_states) + and all( + state.get("circuit_present") is True + and state.get("circuit_alive") is True + and state.get("circuit_blocked") is False + for state in guarded_states + ), + "pings_in_transit_max": round(_maximum(guarded_states, "pings_in_transit")), + "packets_in_delta": _counter_delta(capture_states, "packets_in_total"), + "packets_lost_delta": _counter_delta(capture_states, "packets_lost_total"), + } + + +def collect(config: Mapping[str, Any], api: ViewerAPI) -> dict[str, Any]: + run = dict(config["run"]) + poll_interval = float(run["poll_interval_seconds"]) + validity_config = config.get("validity", {}) + if not isinstance(validity_config, Mapping): + raise ProtocolError("validity config is not a map") + policy = validity_config.get("policy", {}) + operator = validity_config.get("operator", {}) + workload_id = validity_config.get("workload_id") + if not isinstance(policy, Mapping) or not isinstance(operator, Mapping): + raise ProtocolError("validity policy or operator state is not a map") + appearance_diagnostics = config.get("appearance_diagnostics", False) + if not isinstance(appearance_diagnostics, bool): + raise ProtocolError("appearance_diagnostics is not a boolean") + startup_deadline = time.monotonic() + float(config["startup_timeout_seconds"]) + latest_stats: Mapping[str, Any] | None = None + while time.monotonic() < startup_deadline: + latest_stats = api.perf_data() + if latest_stats.get("renderer_schema_version") == SCHEMA_VERSION and latest_stats.get("renderer_ready"): + break + time.sleep(poll_interval) + else: + raise ProtocolError("viewer did not reach the started state before the startup timeout") + + requested_settings = config.get("requested_settings", {}) + if not isinstance(requested_settings, Mapping): + raise ProtocolError("requested_settings is not a map") + apply_requested_settings(requested_settings, api) + normalize_renderer_display(api) + latest_stats = wait_for_display_contract( + requested_settings, + api, + min(10.0, float(config["startup_timeout_seconds"])), + poll_interval, + ) + warmup_started = time.monotonic() + warmup_deadline = warmup_started + float(run["warmup_seconds"]) + initial_scene, initial_appearance = sample_scene_and_appearance( + latest_stats, api, appearance_diagnostics + ) + warmup_observations: list[tuple[float, dict[str, Any]]] = [ + (0.0, initial_scene) + ] + warmup_appearance = ( + [initial_appearance] + if initial_appearance is not None + else [] + ) + while time.monotonic() < warmup_deadline: + latest_stats = api.perf_data() + scene_state, appearance = sample_scene_and_appearance( + latest_stats, api, appearance_diagnostics + ) + warmup_observations.append((time.monotonic() - warmup_started, scene_state)) + if appearance is not None: + warmup_appearance.append(appearance) + time.sleep(poll_interval) + latest_stats = api.perf_data() + scene_state, appearance = sample_scene_and_appearance( + latest_stats, api, appearance_diagnostics + ) + warmup_observations.append((time.monotonic() - warmup_started, scene_state)) + if appearance is not None: + warmup_appearance.append(appearance) + + settle_cutoff = warmup_observations[-1][0] - float(policy["settle_seconds"]) + settle_start = 0 + for index, (elapsed, _) in enumerate(warmup_observations): + if elapsed <= settle_cutoff: + settle_start = index + else: + break + settle_observations = warmup_observations[settle_start:] + settle_states = [state for _, state in settle_observations] + settle_appearance = warmup_appearance[settle_start:] if appearance_diagnostics else [] + settle_seconds_observed = settle_observations[-1][0] - settle_observations[0][0] + + assert latest_stats is not None + first_frame = _latest_frame_number(latest_stats) + captured: dict[int, dict[str, Any]] = {} + capture_states = [_scene_state(latest_stats)] + capture_appearance = [warmup_appearance[-1]] if appearance_diagnostics else [] + capture_deadline = time.monotonic() + float(run["duration_seconds"]) + while time.monotonic() < capture_deadline: + latest_stats = api.perf_data() + _merge_frames(captured, latest_stats, first_frame) + scene_state, appearance = sample_scene_and_appearance( + latest_stats, api, appearance_diagnostics + ) + capture_states.append(scene_state) + if appearance is not None: + capture_appearance.append(appearance) + time.sleep(poll_interval) + latest_stats = api.perf_data() + _merge_frames(captured, latest_stats, first_frame) + scene_state, appearance = sample_scene_and_appearance( + latest_stats, api, appearance_diagnostics + ) + capture_states.append(scene_state) + if appearance is not None: + capture_appearance.append(appearance) + + context = sanitize({**latest_stats.get("renderer_context", {}), **config["context"]}) + context["effective_settings_hash"] = canonical_hash(context.get("effective_settings", {})) + context["feature_flags_hash"] = canonical_hash(context.get("feature_flags", {})) + context["gl_extensions_hash"] = canonical_hash(context.get("gl_extensions", [])) + frames = [captured[key] for key in sorted(captured)] + _add_unclassified_time(frames) + instrumentation = dict(latest_stats.get("renderer_instrumentation", {})) + instrumentation["mode"] = "steady-low-overhead" + observed = summarize_scene_validity( + policy, + settle_states, + capture_states, + frames, + settle_seconds_observed, + ) + gates = validity_gate_results(workload_id, operator, observed, policy) + appearance = ( + summarize_appearance_attribution( + [*settle_states, *capture_states], + [*settle_appearance, *capture_appearance], + ) + if appearance_diagnostics + else None + ) + if appearance is not None: + observed["appearance"] = appearance + validity = { + "workload_id": workload_id, + "policy": dict(policy), + "policy_hash": canonical_hash(policy), + "operator": dict(operator), + "observed": observed, + "gates": gates, + } + result: dict[str, Any] = { + "schema_version": SCHEMA_VERSION, + "status": "valid", + "failure_reason": None, + "run": run, + "context": context, + "instrumentation": instrumentation, + "validity": validity, + "frames": frames, + } + expected_gpu = config.get("expected_gpu_substring") + if context.get("backend_label") != context.get("detected_backend"): + result["status"] = "invalid" + result["failure_reason"] = "requested and detected renderer backends differ" + elif expected_gpu and expected_gpu.lower() not in str(context.get("gpu", "")).lower(): + result["status"] = "invalid" + result["failure_reason"] = "selected GPU does not match the expected GPU" + elif len(frames) < max(10, int(float(run["duration_seconds"]))): + result["status"] = "invalid" + result["failure_reason"] = "too few rendered frames captured" + elif not all(gates.values()): + failed = ", ".join(name for name, passed in gates.items() if not passed) + result["status"] = "invalid" + result["failure_reason"] = f"scene validity gates failed: {failed}" + else: + result["summary"] = summarize_result(result) + return sanitize(result) + + +def write_result(path: Path, result: Mapping[str, Any]) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(json.dumps(sanitize(result), indent=2, sort_keys=True) + "\n", encoding="utf-8") + + +def main() -> int: + parser = argparse.ArgumentParser(allow_abbrev=False) + parser.add_argument("--config", required=True) + args = parser.parse_args() + config = json.loads(Path(args.config).read_text(encoding="utf-8")) + output = Path(config["output"]) + api: ViewerAPI | None = None + try: + api = ViewerAPI() + result = collect(config, api) + write_result(output, result) + return 0 if result["status"] == "valid" else 1 + except Exception as error: + failure = sanitize({ + "schema_version": SCHEMA_VERSION, + "status": "invalid", + "failure_reason": f"{type(error).__name__}: {error}", + "run": config.get("run", {}), + "context": config.get("context", {}), + "instrumentation": {"mode": "steady-low-overhead"}, + "frames": [], + }) + write_result(output, failure) + return 1 + finally: + if api is not None: + try: + api.request_quit() + except Exception: + pass + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/perf/renderer-benchmark-manifest.schema.json b/scripts/perf/renderer-benchmark-manifest.schema.json new file mode 100644 index 00000000000..4b89af9e85c --- /dev/null +++ b/scripts/perf/renderer-benchmark-manifest.schema.json @@ -0,0 +1,70 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://secondlife.com/schemas/renderer-benchmark-manifest-v3.json", + "title": "Second Life renderer benchmark manifest", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "id", "description", "cache_mode", "capture", "settings", "validity", "workload"], + "properties": { + "schema_version": {"const": 3}, + "id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]+$"}, + "description": {"type": "string", "minLength": 1}, + "cache_mode": {"enum": ["warm", "cold"]}, + "capture": { + "type": "object", + "additionalProperties": false, + "required": ["warmup_seconds", "duration_seconds", "repeats", "poll_interval_seconds"], + "properties": { + "warmup_seconds": {"type": "number", "exclusiveMinimum": 0}, + "duration_seconds": {"type": "number", "exclusiveMinimum": 0}, + "repeats": {"type": "integer", "minimum": 1}, + "poll_interval_seconds": {"type": "number", "exclusiveMinimum": 0} + } + }, + "settings": { + "type": "object", + "required": ["RenderBenchmarkUIScale", "RenderHiDPI", "WindowHeight", "WindowMaximized", "WindowWidth"], + "properties": { + "RenderBenchmarkUIScale": {"const": 1.0}, + "RenderHiDPI": {"const": true}, + "UIScaleFactor": false, + "WindowHeight": {"const": 720}, + "WindowMaximized": {"const": false}, + "WindowWidth": {"const": 1280} + } + }, + "validity": { + "type": "object", + "additionalProperties": false, + "required": [ + "asset_mode", "population_mode", "ui_mode", "settle_seconds", + "max_camera_translation_m", "max_camera_rotation_rad", "max_agent_travel_m", + "max_visible_avatar_delta", "max_active_object_delta", "max_new_objects", "max_sim_ping_ms" + ], + "properties": { + "asset_mode": {"enum": ["settled", "streaming"]}, + "population_mode": {"enum": ["stable", "observed"]}, + "ui_mode": {"enum": ["clear", "controlled"]}, + "settle_seconds": {"type": "number", "exclusiveMinimum": 0}, + "max_camera_translation_m": {"type": "number", "exclusiveMinimum": 0}, + "max_camera_rotation_rad": {"type": "number", "exclusiveMinimum": 0}, + "max_agent_travel_m": {"type": "number", "minimum": 0}, + "max_visible_avatar_delta": {"type": "integer", "minimum": 0}, + "max_active_object_delta": {"type": "integer", "minimum": 0}, + "max_new_objects": {"type": "integer", "minimum": 0}, + "max_sim_ping_ms": {"type": "number", "exclusiveMinimum": 0} + } + }, + "workload": { + "type": "object", + "additionalProperties": false, + "required": ["operator_setup", "camera", "observations", "invalid_if"], + "properties": { + "operator_setup": {"type": "string"}, + "camera": {"type": "string"}, + "observations": {"type": "array", "items": {"type": "string"}}, + "invalid_if": {"type": "array", "items": {"type": "string"}} + } + } + } +} diff --git a/scripts/perf/renderer-benchmark-result.schema.json b/scripts/perf/renderer-benchmark-result.schema.json new file mode 100644 index 00000000000..1be15ba5081 --- /dev/null +++ b/scripts/perf/renderer-benchmark-result.schema.json @@ -0,0 +1,359 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://secondlife.com/schemas/renderer-benchmark-result-v3.json", + "title": "Second Life renderer benchmark result", + "type": "object", + "additionalProperties": false, + "required": ["schema_version", "status", "failure_reason", "run", "context", "instrumentation", "validity", "frames"], + "properties": { + "schema_version": {"const": 3}, + "status": {"enum": ["valid", "invalid"]}, + "failure_reason": {"type": ["string", "null"]}, + "run": {"$ref": "#/$defs/run"}, + "context": {"type": "object"}, + "instrumentation": {"type": "object"}, + "validity": {"$ref": "#/$defs/validity"}, + "frames": {"type": "array", "items": {"$ref": "#/$defs/frame"}}, + "summary": {"$ref": "#/$defs/summary"} + }, + "allOf": [ + { + "if": {"properties": {"status": {"const": "valid"}}}, + "then": { + "required": ["summary"], + "properties": { + "failure_reason": {"type": "null"}, + "context": {"$ref": "#/$defs/context"}, + "instrumentation": {"$ref": "#/$defs/instrumentation"}, + "frames": {"type": "array", "minItems": 1, "items": {"$ref": "#/$defs/frame"}} + } + } + }, + { + "if": {"properties": {"status": {"const": "invalid"}}}, + "then": {"properties": {"failure_reason": {"type": "string", "minLength": 1}}} + } + ], + "$defs": { + "nonNegativeNumber": {"type": "number", "minimum": 0}, + "positiveNumber": {"type": "number", "exclusiveMinimum": 0}, + "appearanceRequiredParts": { + "type": "object", + "additionalProperties": false, + "required": ["shape", "skin", "hair", "eyes"], + "properties": { + "shape": {"type": "boolean"}, + "skin": {"type": "boolean"}, + "hair": {"type": "boolean"}, + "eyes": {"type": "boolean"} + } + }, + "appearanceAttribution": { + "type": "object", + "additionalProperties": false, + "required": [ + "classification", "avatar_valid", "cof_present", "cof_complete", + "cof_change_in_progress", "required_links_resolved", + "required_wearables_delivered", "avatar_loaded" + ], + "properties": { + "classification": { + "enum": [ + "avatar-unavailable", "cof-incomplete", + "required-link-missing-or-unresolved", + "wearable-delivery-pending-or-failed", + "avatar-later-blocker", "ready", "unknown" + ] + }, + "avatar_valid": {"type": "boolean"}, + "cof_present": {"type": "boolean"}, + "cof_complete": {"type": "boolean"}, + "cof_change_in_progress": {"type": "boolean"}, + "required_links_resolved": {"$ref": "#/$defs/appearanceRequiredParts"}, + "required_wearables_delivered": {"$ref": "#/$defs/appearanceRequiredParts"}, + "avatar_loaded": {"type": "boolean"} + } + }, + "run": { + "type": "object", + "additionalProperties": false, + "required": [ + "scenario", "run_number", "cache_mode", "manifest_hash", "settings_hash", + "warmup_seconds", "duration_seconds", "poll_interval_seconds" + ], + "properties": { + "scenario": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]+$"}, + "run_number": {"type": "integer", "minimum": 1}, + "cache_mode": {"enum": ["warm", "cold"]}, + "manifest_hash": {"type": "string", "minLength": 1}, + "settings_hash": {"type": "string", "minLength": 1}, + "warmup_seconds": {"$ref": "#/$defs/positiveNumber"}, + "duration_seconds": {"$ref": "#/$defs/positiveNumber"}, + "poll_interval_seconds": {"$ref": "#/$defs/positiveNumber"} + } + }, + "context": { + "type": "object", + "additionalProperties": false, + "required": [ + "backend_label", "backing_height", "backing_scale_x", "backing_scale_y", "backing_width", + "build_type", "configured_ui_scale", "cpu", "detected_backend", "driver", + "effective_display_scale_x", "effective_display_scale_y", + "effective_settings", "effective_settings_hash", "feature_flags", "feature_flags_hash", + "git_commit", "git_diff_hash", "git_dirty", "gl_extensions", "gl_extensions_hash", "gl_limits", "gpu", "gpu_vendor", + "hardware_label", "height", "logical_core_count", "logical_height", "logical_width", "opengl_profile", "opengl_version", + "os", "shader_level", "viewer_channel", "viewer_version", "width" + ], + "properties": { + "backend_label": {"enum": ["native-gl", "zink"]}, + "backing_height": {"type": "integer", "minimum": 1}, + "backing_scale_x": {"$ref": "#/$defs/positiveNumber"}, + "backing_scale_y": {"$ref": "#/$defs/positiveNumber"}, + "backing_width": {"type": "integer", "minimum": 1}, + "build_type": {"type": "string", "minLength": 1}, + "configured_ui_scale": {"$ref": "#/$defs/positiveNumber"}, + "cpu": {"type": "string", "minLength": 1}, + "detected_backend": {"enum": ["native-gl", "zink"]}, + "driver": {"type": "string"}, + "effective_display_scale_x": {"$ref": "#/$defs/positiveNumber"}, + "effective_display_scale_y": {"$ref": "#/$defs/positiveNumber"}, + "effective_settings": {"type": "object"}, + "effective_settings_hash": {"type": "string", "minLength": 1}, + "feature_flags": {"type": "object"}, + "feature_flags_hash": {"type": "string", "minLength": 1}, + "git_commit": {"type": "string", "pattern": "^[a-f0-9]{40}$"}, + "git_diff_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "git_dirty": {"type": "boolean"}, + "gl_extensions": {"type": "array", "items": {"type": "string"}, "uniqueItems": true}, + "gl_extensions_hash": {"type": "string", "minLength": 1}, + "gl_limits": { + "type": "object", + "additionalProperties": false, + "required": ["max_samples", "max_texture_image_units", "max_texture_size", "max_uniform_block_size"], + "properties": { + "max_samples": {"type": "integer", "minimum": 0}, + "max_texture_image_units": {"type": "integer", "minimum": 0}, + "max_texture_size": {"type": "integer", "minimum": 0}, + "max_uniform_block_size": {"type": "integer", "minimum": 0} + } + }, + "gpu": {"type": "string", "minLength": 1}, + "gpu_vendor": {"type": "string"}, + "gpu_vram_mb": {"type": "integer", "minimum": 0}, + "hardware_label": {"type": "string", "minLength": 1}, + "height": {"type": "integer", "minimum": 1}, + "logical_core_count": {"type": "integer", "minimum": 0}, + "logical_height": {"type": "integer", "minimum": 1}, + "logical_width": {"type": "integer", "minimum": 1}, + "opengl_profile": {"enum": ["core", "compatibility"]}, + "opengl_version": {"type": "string", "minLength": 1}, + "os": {"type": "string", "minLength": 1}, + "shader_level": {"type": "integer", "minimum": 0}, + "viewer_channel": {"type": "string", "minLength": 1}, + "viewer_version": {"type": "string", "minLength": 1}, + "width": {"type": "integer", "minimum": 1} + } + }, + "instrumentation": { + "type": "object", + "additionalProperties": false, + "required": [ + "mode", "compile_time_enabled", "cpu_phase_timing", "resource_counters", "gpu_pass_timing", + "gpu_query_readback_in_steady_loop" + ], + "properties": { + "mode": {"const": "steady-low-overhead"}, + "compile_time_enabled": {"const": true}, + "cpu_phase_timing": {"const": true}, + "resource_counters": {"const": true}, + "gpu_pass_timing": {"const": "external-diagnostic"}, + "gpu_query_readback_in_steady_loop": {"const": false} + } + }, + "validityPolicy": { + "type": "object", + "additionalProperties": false, + "required": [ + "asset_mode", "population_mode", "ui_mode", "settle_seconds", + "max_camera_translation_m", "max_camera_rotation_rad", "max_agent_travel_m", + "max_visible_avatar_delta", "max_active_object_delta", "max_new_objects", "max_sim_ping_ms" + ], + "properties": { + "asset_mode": {"enum": ["settled", "streaming"]}, + "population_mode": {"enum": ["stable", "observed"]}, + "ui_mode": {"enum": ["clear", "controlled"]}, + "settle_seconds": {"$ref": "#/$defs/positiveNumber"}, + "max_camera_translation_m": {"$ref": "#/$defs/positiveNumber"}, + "max_camera_rotation_rad": {"$ref": "#/$defs/positiveNumber"}, + "max_agent_travel_m": {"$ref": "#/$defs/nonNegativeNumber"}, + "max_visible_avatar_delta": {"type": "integer", "minimum": 0}, + "max_active_object_delta": {"type": "integer", "minimum": 0}, + "max_new_objects": {"type": "integer", "minimum": 0}, + "max_sim_ping_ms": {"$ref": "#/$defs/positiveNumber"} + } + }, + "validity": { + "type": "object", + "additionalProperties": false, + "required": ["workload_id", "policy", "policy_hash", "operator", "observed", "gates"], + "properties": { + "workload_id": {"type": "string", "pattern": "^[a-z0-9][a-z0-9-]{2,63}$"}, + "policy": {"$ref": "#/$defs/validityPolicy"}, + "policy_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "operator": { + "type": "object", + "additionalProperties": false, + "required": ["power_source", "low_power_mode", "thermal_state", "scene_events", "ui_state", "camera_state"], + "properties": { + "power_source": {"enum": ["ac", "battery", "unknown"]}, + "low_power_mode": {"enum": ["off", "on", "unknown"]}, + "thermal_state": {"enum": ["nominal", "elevated", "throttled", "unknown"]}, + "scene_events": {"enum": ["none", "observed", "unknown"]}, + "ui_state": {"enum": ["approved", "unapproved", "unknown"]}, + "camera_state": {"enum": ["approved", "unapproved", "unknown"]} + } + }, + "observed": { + "type": "object", + "additionalProperties": false, + "required": [ + "settle_seconds_observed", "destination_ready", "teleport_seen", "progress_seen", + "background_frame_count", "camera_animating_seen", "camera_translation_m", "camera_rotation_rad", + "agent_travel_m", "agent_speed_mps_max", "view_hash", "modal_dialog_max", "alert_toast_seen", + "welcome_pack_seen", "hint_seen", "closeable_floaters_closed", "texture_fetch_requests_max", + "texture_http_requests_max", "texture_create_queue_max", "texture_fast_cache_max", + "texture_upload_count_delta", "mesh_lod_unresolved_max", "mesh_skin_unresolved_max", + "self_avatar_loaded", "visible_avatars_min", "visible_avatars_max", "active_objects_min", + "active_objects_max", "new_objects_total", "sim_ping_ms_max", "circuit_healthy", + "pings_in_transit_max", "packets_in_delta", "packets_lost_delta" + ], + "properties": { + "settle_seconds_observed": {"$ref": "#/$defs/nonNegativeNumber"}, + "destination_ready": {"type": "boolean"}, + "teleport_seen": {"type": "boolean"}, + "progress_seen": {"type": "boolean"}, + "background_frame_count": {"type": "integer", "minimum": -1}, + "camera_animating_seen": {"type": "boolean"}, + "camera_translation_m": {"type": "number", "minimum": -1}, + "camera_rotation_rad": {"type": "number", "minimum": -1}, + "agent_travel_m": {"type": "number", "minimum": -1}, + "agent_speed_mps_max": {"type": "number", "minimum": -1}, + "view_hash": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, + "modal_dialog_max": {"type": "integer", "minimum": -1}, + "alert_toast_seen": {"type": "boolean"}, + "welcome_pack_seen": {"type": "boolean"}, + "hint_seen": {"type": "boolean"}, + "closeable_floaters_closed": {"type": "boolean"}, + "texture_fetch_requests_max": {"type": "integer", "minimum": -1}, + "texture_http_requests_max": {"type": "integer", "minimum": -1}, + "texture_create_queue_max": {"type": "integer", "minimum": -1}, + "texture_fast_cache_max": {"type": "integer", "minimum": -1}, + "texture_upload_count_delta": {"type": "number", "minimum": -1}, + "mesh_lod_unresolved_max": {"type": "integer", "minimum": -1}, + "mesh_skin_unresolved_max": {"type": "integer", "minimum": -1}, + "self_avatar_loaded": {"type": "boolean"}, + "appearance": {"$ref": "#/$defs/appearanceAttribution"}, + "visible_avatars_min": {"type": "integer", "minimum": -1}, + "visible_avatars_max": {"type": "integer", "minimum": -1}, + "active_objects_min": {"type": "integer", "minimum": -1}, + "active_objects_max": {"type": "integer", "minimum": -1}, + "new_objects_total": {"type": "number", "minimum": -1}, + "sim_ping_ms_max": {"type": "number", "minimum": -1}, + "circuit_healthy": {"type": "boolean"}, + "pings_in_transit_max": {"type": "integer", "minimum": -1}, + "packets_in_delta": {"type": "number", "minimum": -1}, + "packets_lost_delta": {"type": "number", "minimum": -1} + } + }, + "gates": { + "type": "object", + "additionalProperties": false, + "required": [ + "workload", "placement", "focus", "camera", "avatar", "ui", "assets", + "population", "network", "scene_events", "power", "thermal" + ], + "properties": { + "workload": {"type": "boolean"}, + "placement": {"type": "boolean"}, + "focus": {"type": "boolean"}, + "camera": {"type": "boolean"}, + "avatar": {"type": "boolean"}, + "ui": {"type": "boolean"}, + "assets": {"type": "boolean"}, + "population": {"type": "boolean"}, + "network": {"type": "boolean"}, + "scene_events": {"type": "boolean"}, + "power": {"type": "boolean"}, + "thermal": {"type": "boolean"} + } + } + } + }, + "frame": { + "type": "object", + "additionalProperties": false, + "required": ["frame_number", "frame_time_ms"], + "properties": { + "frame_number": {"type": "number", "minimum": 0}, + "frame_time_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "do_frame_time_us": {"$ref": "#/$defs/nonNegativeNumber"}, + "sim_ping_ms": {"type": "number"}, + "visible_avatars": {"type": "number", "minimum": 0}, + "active_objects": {"type": "number", "minimum": 0}, + "new_objects": {"$ref": "#/$defs/nonNegativeNumber"}, + "camera_translation_m": {"$ref": "#/$defs/nonNegativeNumber"}, + "camera_rotation_rad": {"$ref": "#/$defs/nonNegativeNumber"}, + "geometry_create_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "partition_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "geometry_update_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "cull_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "shadows_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "texture_work_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "state_sort_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "rebuild_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "submission_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "lighting_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "ui_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "swap_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "idle_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "unclassified_ms": {"$ref": "#/$defs/nonNegativeNumber"}, + "draw_calls": {"type": "integer", "minimum": 0}, + "batch_size_min": {"$ref": "#/$defs/nonNegativeNumber"}, + "batch_size_max": {"$ref": "#/$defs/nonNegativeNumber"}, + "batch_size_mean": {"$ref": "#/$defs/nonNegativeNumber"}, + "ktriangles": {"$ref": "#/$defs/nonNegativeNumber"}, + "texture_upload_count_total": {"$ref": "#/$defs/nonNegativeNumber"}, + "texture_upload_bytes_total": {"$ref": "#/$defs/nonNegativeNumber"}, + "texture_readback_count_total": {"$ref": "#/$defs/nonNegativeNumber"}, + "texture_readback_time_us_total": {"$ref": "#/$defs/nonNegativeNumber"}, + "texture_wait_count_total": {"$ref": "#/$defs/nonNegativeNumber"}, + "texture_wait_time_us_total": {"$ref": "#/$defs/nonNegativeNumber"}, + "shader_compile_count_total": {"$ref": "#/$defs/nonNegativeNumber"}, + "shader_compile_time_us_total": {"$ref": "#/$defs/nonNegativeNumber"}, + "shader_bind_count_total": {"$ref": "#/$defs/nonNegativeNumber"} + } + }, + "summary": { + "type": "object", + "additionalProperties": false, + "required": ["sample_count", "frame_time_ms", "one_percent_low_fps", "phase_p95_ms", "counter_deltas"], + "properties": { + "sample_count": {"type": "integer", "minimum": 1}, + "frame_time_ms": { + "type": "object", + "additionalProperties": false, + "required": ["median", "p95", "p99", "worst"], + "properties": { + "median": {"$ref": "#/$defs/nonNegativeNumber"}, + "p95": {"$ref": "#/$defs/nonNegativeNumber"}, + "p99": {"$ref": "#/$defs/nonNegativeNumber"}, + "worst": {"$ref": "#/$defs/nonNegativeNumber"} + } + }, + "one_percent_low_fps": {"$ref": "#/$defs/nonNegativeNumber"}, + "phase_p95_ms": {"type": "object", "additionalProperties": {"$ref": "#/$defs/nonNegativeNumber"}}, + "counter_deltas": {"type": "object", "additionalProperties": {"$ref": "#/$defs/nonNegativeNumber"}} + } + } + } +} diff --git a/scripts/perf/scenarios/avatar-heavy-v1.json b/scripts/perf/scenarios/avatar-heavy-v1.json new file mode 100644 index 00000000000..940316b3b7b --- /dev/null +++ b/scripts/perf/scenarios/avatar-heavy-v1.json @@ -0,0 +1,43 @@ +{ + "schema_version": 3, + "id": "avatar-heavy-v1", + "description": "Controlled crowd view stressing avatar updates, skinning, attachments, and impostors.", + "cache_mode": "warm", + "capture": {"warmup_seconds": 30, "duration_seconds": 120, "repeats": 5, "poll_interval_seconds": 0.5}, + "settings": { + "AutoTuneFPS": false, + "RenderAvatarMaxNonImpostors": 16, + "RenderBenchmarkUIScale": 1.0, + "RenderDeferred": true, + "RenderFarClip": 128.0, + "RenderGLContextCoreProfile": true, + "RenderHiDPI": true, + "RenderReflectionsEnabled": true, + "RenderShadowDetail": 2, + "RenderVSyncEnable": false, + "RenderVolumeLODFactor": 1.0, + "WindowHeight": 720, + "WindowMaximized": false, + "WindowWidth": 1280, + "YieldTime": -1 + }, + "validity": { + "asset_mode": "settled", + "population_mode": "stable", + "ui_mode": "clear", + "settle_seconds": 15, + "max_camera_translation_m": 0.1, + "max_camera_rotation_rad": 0.05, + "max_agent_travel_m": 0.0, + "max_visible_avatar_delta": 0, + "max_active_object_delta": 0, + "max_new_objects": 0, + "max_sim_ping_ms": 600 + }, + "workload": { + "operator_setup": "Use a controlled crowd or replay with a recorded avatar count and the same attachment complexity.", + "camera": "Fixed crowd-facing camera with identical impostor distances.", + "observations": ["visible avatars", "non-impostor avatars", "attachment complexity", "simulator ping"], + "invalid_if": ["avatar count changes", "outfits or attachment complexity change", "bakes are incomplete", "camera moves", "graphics features differ"] + } +} diff --git a/scripts/perf/scenarios/cold-streaming-v1.json b/scripts/perf/scenarios/cold-streaming-v1.json new file mode 100644 index 00000000000..ed0827a93b1 --- /dev/null +++ b/scripts/perf/scenarios/cold-streaming-v1.json @@ -0,0 +1,42 @@ +{ + "schema_version": 3, + "id": "cold-streaming-v1", + "description": "Isolated empty-cache load exposing decode, upload, allocation, and synchronization work.", + "cache_mode": "cold", + "capture": {"warmup_seconds": 5, "duration_seconds": 120, "repeats": 5, "poll_interval_seconds": 0.5}, + "settings": { + "AutoTuneFPS": false, + "RenderBenchmarkUIScale": 1.0, + "RenderDeferred": true, + "RenderFarClip": 128.0, + "RenderGLContextCoreProfile": true, + "RenderHiDPI": true, + "RenderReflectionsEnabled": true, + "RenderShadowDetail": 2, + "RenderVSyncEnable": false, + "RenderVolumeLODFactor": 1.0, + "WindowHeight": 720, + "WindowMaximized": false, + "WindowWidth": 1280, + "YieldTime": -1 + }, + "validity": { + "asset_mode": "streaming", + "population_mode": "observed", + "ui_mode": "clear", + "settle_seconds": 5, + "max_camera_translation_m": 0.1, + "max_camera_rotation_rad": 0.05, + "max_agent_travel_m": 0.0, + "max_visible_avatar_delta": 0, + "max_active_object_delta": 0, + "max_new_objects": 0, + "max_sim_ping_ms": 600 + }, + "workload": { + "operator_setup": "Start with the runner's isolated empty cache and use the same scene entry point for every repeat.", + "camera": "Fixed camera from login through capture.", + "observations": ["time until scene settles", "texture upload bytes", "texture waits", "active objects", "simulator ping"], + "invalid_if": ["the temporary cache was reused", "network service is degraded", "scene population changes materially", "camera moves", "graphics features differ"] + } +} diff --git a/scripts/perf/scenarios/draw-alpha-heavy-v1.json b/scripts/perf/scenarios/draw-alpha-heavy-v1.json new file mode 100644 index 00000000000..1a3136eb5cb --- /dev/null +++ b/scripts/perf/scenarios/draw-alpha-heavy-v1.json @@ -0,0 +1,42 @@ +{ + "schema_version": 3, + "id": "draw-alpha-heavy-v1", + "description": "Object- and transparency-heavy view stressing cull, sort, batches, and GL submission.", + "cache_mode": "warm", + "capture": {"warmup_seconds": 30, "duration_seconds": 120, "repeats": 5, "poll_interval_seconds": 0.5}, + "settings": { + "AutoTuneFPS": false, + "RenderBenchmarkUIScale": 1.0, + "RenderDeferred": true, + "RenderFarClip": 256.0, + "RenderGLContextCoreProfile": true, + "RenderHiDPI": true, + "RenderReflectionsEnabled": true, + "RenderShadowDetail": 2, + "RenderVSyncEnable": false, + "RenderVolumeLODFactor": 2.0, + "WindowHeight": 720, + "WindowMaximized": false, + "WindowWidth": 1280, + "YieldTime": -1 + }, + "validity": { + "asset_mode": "settled", + "population_mode": "stable", + "ui_mode": "clear", + "settle_seconds": 15, + "max_camera_translation_m": 0.1, + "max_camera_rotation_rad": 0.05, + "max_agent_travel_m": 0.0, + "max_visible_avatar_delta": 0, + "max_active_object_delta": 0, + "max_new_objects": 0, + "max_sim_ping_ms": 600 + }, + "workload": { + "operator_setup": "Use a captured or controlled scene with stable object, material, and blended-face counts.", + "camera": "Fixed view through overlapping alpha surfaces and dense geometry.", + "observations": ["active objects", "draw calls", "batch size", "triangles", "shader changes"], + "invalid_if": ["scene content changes", "object rebuild continues after warm-up", "camera moves", "graphics features differ"] + } +} diff --git a/scripts/perf/scenarios/gpu-pass-heavy-v1.json b/scripts/perf/scenarios/gpu-pass-heavy-v1.json new file mode 100644 index 00000000000..5746b324541 --- /dev/null +++ b/scripts/perf/scenarios/gpu-pass-heavy-v1.json @@ -0,0 +1,43 @@ +{ + "schema_version": 3, + "id": "gpu-pass-heavy-v1", + "description": "High-fill view for shadows, reflection probes, deferred lighting, and bandwidth pressure.", + "cache_mode": "warm", + "capture": {"warmup_seconds": 30, "duration_seconds": 120, "repeats": 5, "poll_interval_seconds": 0.5}, + "settings": { + "AutoTuneFPS": false, + "RenderBenchmarkUIScale": 1.0, + "RenderDeferred": true, + "RenderFarClip": 256.0, + "RenderGLContextCoreProfile": true, + "RenderHiDPI": true, + "RenderReflectionProbeDetail": 2, + "RenderReflectionsEnabled": true, + "RenderShadowDetail": 2, + "RenderVSyncEnable": false, + "RenderVolumeLODFactor": 2.0, + "WindowHeight": 720, + "WindowMaximized": false, + "WindowWidth": 1280, + "YieldTime": -1 + }, + "validity": { + "asset_mode": "settled", + "population_mode": "stable", + "ui_mode": "clear", + "settle_seconds": 15, + "max_camera_translation_m": 0.1, + "max_camera_rotation_rad": 0.05, + "max_agent_travel_m": 0.0, + "max_visible_avatar_delta": 0, + "max_active_object_delta": 0, + "max_new_objects": 0, + "max_sim_ping_ms": 600 + }, + "workload": { + "operator_setup": "Use a stable scene with shadows and probes visible; pair steady runs with a separately labeled external GPU capture.", + "camera": "Fixed high-fill view with unchanged render resolution.", + "observations": ["GPU pass capture", "render resolution", "shadow updates", "reflection probe updates", "thermal state"], + "invalid_if": ["no matching diagnostic GPU capture exists", "resolution changes", "probe or shadow state changes", "power or thermal throttling occurs"] + } +} diff --git a/scripts/perf/scenarios/steady-warm-v1.json b/scripts/perf/scenarios/steady-warm-v1.json new file mode 100644 index 00000000000..f7200d27dc8 --- /dev/null +++ b/scripts/perf/scenarios/steady-warm-v1.json @@ -0,0 +1,42 @@ +{ + "schema_version": 3, + "id": "steady-warm-v1", + "description": "Settled static view for the lowest-noise CPU and submission baseline.", + "cache_mode": "warm", + "capture": {"warmup_seconds": 30, "duration_seconds": 120, "repeats": 5, "poll_interval_seconds": 0.5}, + "settings": { + "AutoTuneFPS": false, + "RenderBenchmarkUIScale": 1.0, + "RenderDeferred": true, + "RenderFarClip": 128.0, + "RenderGLContextCoreProfile": true, + "RenderHiDPI": true, + "RenderReflectionsEnabled": true, + "RenderShadowDetail": 2, + "RenderVSyncEnable": false, + "RenderVolumeLODFactor": 1.0, + "WindowHeight": 720, + "WindowMaximized": false, + "WindowWidth": 1280, + "YieldTime": -1 + }, + "validity": { + "asset_mode": "settled", + "population_mode": "stable", + "ui_mode": "clear", + "settle_seconds": 15, + "max_camera_translation_m": 0.1, + "max_camera_rotation_rad": 0.05, + "max_agent_travel_m": 0.0, + "max_visible_avatar_delta": 0, + "max_active_object_delta": 0, + "max_new_objects": 0, + "max_sim_ping_ms": 600 + }, + "workload": { + "operator_setup": "Use a repeatable public or captured scene, wait for visible texture and mesh loading to settle, then leave the avatar stationary.", + "camera": "Fixed camera transform recorded in the run notes; do not move during capture.", + "observations": ["visible avatars", "active objects", "simulator ping", "asset-loading indicator"], + "invalid_if": ["asset loading is incomplete after warm-up", "camera or avatar moves", "window loses focus", "power or thermal throttling occurs", "graphics features differ"] + } +} diff --git a/scripts/perf/scenarios/ui-hud-heavy-v1.json b/scripts/perf/scenarios/ui-hud-heavy-v1.json new file mode 100644 index 00000000000..0483a323fcf --- /dev/null +++ b/scripts/perf/scenarios/ui-hud-heavy-v1.json @@ -0,0 +1,42 @@ +{ + "schema_version": 3, + "id": "ui-hud-heavy-v1", + "description": "Repeatable UI, text, HUD attachment, and media composition workload.", + "cache_mode": "warm", + "capture": {"warmup_seconds": 30, "duration_seconds": 120, "repeats": 5, "poll_interval_seconds": 0.5}, + "settings": { + "AutoTuneFPS": false, + "RenderBenchmarkUIScale": 1.0, + "RenderDeferred": true, + "RenderFarClip": 128.0, + "RenderGLContextCoreProfile": true, + "RenderHiDPI": true, + "RenderReflectionsEnabled": true, + "RenderShadowDetail": 2, + "RenderVSyncEnable": false, + "RenderVolumeLODFactor": 1.0, + "WindowHeight": 720, + "WindowMaximized": false, + "WindowWidth": 1280, + "YieldTime": -1 + }, + "validity": { + "asset_mode": "settled", + "population_mode": "stable", + "ui_mode": "controlled", + "settle_seconds": 15, + "max_camera_translation_m": 0.1, + "max_camera_rotation_rad": 0.05, + "max_agent_travel_m": 0.0, + "max_visible_avatar_delta": 0, + "max_active_object_delta": 0, + "max_new_objects": 0, + "max_sim_ping_ms": 600 + }, + "workload": { + "operator_setup": "Open the recorded floater layout and HUD set; use deterministic local media or disable media consistently.", + "camera": "Fixed world camera behind an unchanged UI and HUD layout.", + "observations": ["open floaters", "visible text", "HUD attachments", "media playback state"], + "invalid_if": ["floater layout changes", "HUD attachment set changes", "media state differs", "window loses focus", "graphics features differ"] + } +} diff --git a/scripts/perf/tests/test_render_benchmark.py b/scripts/perf/tests/test_render_benchmark.py new file mode 100644 index 00000000000..308e6415fab --- /dev/null +++ b/scripts/perf/tests/test_render_benchmark.py @@ -0,0 +1,1182 @@ +from __future__ import annotations + +from contextlib import redirect_stdout +import copy +from io import StringIO +import json +import os +from pathlib import Path +import tempfile +import unittest +from unittest.mock import patch +import xml.etree.ElementTree as ET + + +PERF_DIR = Path(__file__).resolve().parents[1] +import sys + +sys.path.insert(0, str(PERF_DIR)) + +import render_benchmark as benchmark +import render_benchmark_leap as collector + + +class RendererBenchmarkTests(unittest.TestCase): + def setUp(self) -> None: + self.fixture = benchmark.load_json(PERF_DIR / "fixtures" / "renderer-result-v3.json") + self.operator_args = [ + "--workload-id", "fixture-steady-scene", + "--power-source", "ac", + "--low-power-mode", "off", + "--thermal-state", "nominal", + "--scene-events", "none", + "--ui-state", "approved", + "--camera-state", "approved", + ] + + def match_result_to_manifest(self, result: dict[str, object], manifest: dict[str, object]) -> None: + result["run"]["manifest_hash"] = benchmark.canonical_hash(manifest) + result["run"]["settings_hash"] = benchmark.canonical_hash(manifest["settings"]) + result["validity"]["policy"] = copy.deepcopy(manifest["validity"]) + result["validity"]["policy_hash"] = benchmark.canonical_hash(manifest["validity"]) + + def appearance_attribution(self, classification: str = "ready") -> dict[str, object]: + attribution: dict[str, object] = { + "classification": classification, + "avatar_valid": True, + "cof_present": True, + "cof_complete": True, + "cof_change_in_progress": False, + "required_links_resolved": { + "shape": True, + "skin": True, + "hair": True, + "eyes": True, + }, + "required_wearables_delivered": { + "shape": True, + "skin": True, + "hair": True, + "eyes": True, + }, + "avatar_loaded": True, + } + if classification == "avatar-unavailable": + attribution["avatar_valid"] = False + attribution["avatar_loaded"] = False + elif classification == "cof-incomplete": + attribution["cof_complete"] = False + attribution["avatar_loaded"] = False + elif classification == "required-link-missing-or-unresolved": + attribution["required_links_resolved"]["shape"] = False + attribution["avatar_loaded"] = False + elif classification == "wearable-delivery-pending-or-failed": + attribution["required_wearables_delivered"]["hair"] = False + attribution["avatar_loaded"] = False + elif classification == "avatar-later-blocker": + attribution["avatar_loaded"] = False + return attribution + + def test_all_scenario_manifests_validate(self) -> None: + manifests = sorted((PERF_DIR / "scenarios").glob("*.json")) + self.assertEqual(6, len(manifests)) + for path in manifests: + with self.subTest(path=path.name): + benchmark.validate_manifest(benchmark.load_json(path)) + + def test_all_requested_settings_exist(self) -> None: + settings_path = PERF_DIR.parents[1] / "indra" / "newview" / "app_settings" / "settings.xml" + known_settings = {element.text for element in ET.parse(settings_path).iter("key")} + self.assertEqual(set(), set(benchmark.OPERATIONAL_SETTINGS) - known_settings) + for path in sorted((PERF_DIR / "scenarios").glob("*.json")): + manifest = benchmark.load_json(path) + with self.subTest(path=path.name): + self.assertEqual(set(), set(manifest["settings"]) - known_settings) + + def test_manifest_requires_capture_contract(self) -> None: + manifest = benchmark.load_json(PERF_DIR / "scenarios" / "steady-warm-v1.json") + del manifest["capture"]["duration_seconds"] + with self.assertRaisesRegex(benchmark.BenchmarkError, "duration_seconds"): + benchmark.validate_manifest(manifest) + + def test_manifest_requires_normalized_display_contract(self) -> None: + manifest = benchmark.load_json(PERF_DIR / "scenarios" / "steady-warm-v1.json") + manifest["settings"]["WindowWidth"] = 1920 + with self.assertRaisesRegex(benchmark.BenchmarkError, "display contract mismatch"): + benchmark.validate_manifest(manifest) + + manifest["settings"]["WindowWidth"] = benchmark.BENCHMARK_BACKING_WIDTH + manifest["settings"]["UIScaleFactor"] = 0.5 + with self.assertRaisesRegex(benchmark.BenchmarkError, "must be derived"): + benchmark.validate_manifest(manifest) + + del manifest["settings"]["UIScaleFactor"] + manifest["settings"]["RenderHiDPI"] = False + with self.assertRaisesRegex(benchmark.BenchmarkError, "RenderHiDPI"): + benchmark.validate_manifest(manifest) + + def test_manifest_requires_scene_validity_contract(self) -> None: + manifest = benchmark.load_json(PERF_DIR / "scenarios" / "steady-warm-v1.json") + del manifest["validity"]["asset_mode"] + with self.assertRaisesRegex(benchmark.BenchmarkError, "asset_mode"): + benchmark.validate_manifest(manifest) + + manifest = benchmark.load_json(PERF_DIR / "scenarios" / "steady-warm-v1.json") + manifest["validity"]["settle_seconds"] = 31 + with self.assertRaisesRegex(benchmark.BenchmarkError, "fit inside"): + benchmark.validate_manifest(manifest) + + def test_percentile_uses_linear_interpolation(self) -> None: + self.assertEqual(2.5, benchmark.percentile([1, 2, 3, 4], 0.5)) + self.assertAlmostEqual(3.85, benchmark.percentile([1, 2, 3, 4], 0.95)) + + def test_leap_command_is_encoded_as_llsd_string_notation(self) -> None: + command = "/tmp/viewer plugin.py --config '/tmp/path with spaces/config.json'" + notation = benchmark.llsd_string_notation(command) + self.assertTrue(notation.startswith('"')) + self.assertEqual(command, json.loads(notation)) + + def test_operational_settings_disable_slurl_handoff(self) -> None: + self.assertTrue(benchmark.OPERATIONAL_SETTINGS["AllowMultipleViewers"]) + self.assertFalse(benchmark.OPERATIONAL_SETTINGS["FirstLoginThisInstall"]) + self.assertFalse(benchmark.OPERATIONAL_SETTINGS["MigrateCacheDirectory"]) + self.assertFalse(benchmark.OPERATIONAL_SETTINGS["SLURLPassToOtherInstance"]) + self.assertIn("--noaudio", benchmark.OPERATIONAL_SWITCHES) + self.assertIn("--nonotifications", benchmark.OPERATIONAL_SWITCHES) + self.assertIn("--novoice", benchmark.OPERATIONAL_SWITCHES) + + def test_warm_runs_have_an_unmeasured_prime(self) -> None: + self.assertEqual([0, 1, 2, 3], benchmark.benchmark_run_numbers("warm", 3)) + self.assertEqual([1, 2, 3], benchmark.benchmark_run_numbers("cold", 3)) + self.assertEqual( + [0, 0], + benchmark.benchmark_run_numbers("warm", 5, warm_prime_attempts=2, prime_only=True), + ) + + def test_disposable_cache_selection_is_complete_and_precreated(self) -> None: + manifest = benchmark.load_json(PERF_DIR / "scenarios" / "steady-warm-v1.json") + with tempfile.TemporaryDirectory() as temp_name: + state = Path(temp_name) / "state" + settings = benchmark.benchmark_state_settings(manifest["settings"], state, "warm") + self.assertTrue((state / "user").is_dir()) + self.assertTrue((state / "cache").is_dir()) + self.assertEqual(str(state / "cache"), settings["CacheLocation"]) + self.assertEqual(settings["CacheLocation"], settings["NewCacheLocation"]) + self.assertFalse(settings["MigrateCacheDirectory"]) + self.assertFalse(settings["PurgeCacheOnStartup"]) + self.assertEqual("ready", benchmark._cache_probe(state / "cache")) + + (state / "cache").rmdir() + benchmark.benchmark_state_settings(manifest["settings"], state, "warm", initialize=False) + self.assertFalse((state / "cache").exists()) + self.assertEqual("root-missing", benchmark._cache_probe(state / "cache")) + + def test_cache_probe_reports_cleanup_failure(self) -> None: + original_unlink = Path.unlink + unlink_calls = 0 + + def fail_final_cleanup(path: Path, *args: object, **kwargs: object) -> None: + nonlocal unlink_calls + unlink_calls += 1 + if unlink_calls == 3: + raise OSError("simulated cleanup failure") + original_unlink(path, *args, **kwargs) + + with tempfile.TemporaryDirectory() as temp_name: + with patch.object(Path, "unlink", new=fail_final_cleanup): + self.assertEqual("cleanup-failed", benchmark._cache_probe(Path(temp_name))) + + def test_collector_reapplies_requested_settings_at_runtime(self) -> None: + class FakeAPI: + def __init__(self) -> None: + self.requests: list[tuple[str, dict[str, object]]] = [] + + def request(self, pump: str, data: dict[str, object]) -> dict[str, object]: + self.requests.append((pump, data)) + return {"value": data["value"]} + + api = FakeAPI() + collector.apply_requested_settings({"RenderShadowDetail": 2}, api) + self.assertEqual( + [("LLViewerControl", { + "op": "set", + "group": "Global", + "key": "RenderShadowDetail", + "value": 2, + })], + api.requests, + ) + + def test_collector_requests_display_normalization_after_startup(self) -> None: + class FakeAPI: + def __init__(self) -> None: + self.requests: list[tuple[str, dict[str, object]]] = [] + + def request(self, pump: str, data: dict[str, object]) -> dict[str, object]: + self.requests.append((pump, data)) + return {"accepted": True} + + api = FakeAPI() + collector.normalize_renderer_display(api) + self.assertEqual( + [("LLStats", {"op": "normalizeRendererDisplay"})], + api.requests, + ) + + def test_appearance_sampling_is_prime_only_and_retains_the_last_blocker(self) -> None: + classifications = [ + "cof-incomplete", + "wearable-delivery-pending-or-failed", + "ready", + ] + diagnostics = [ + { + "scene_state": { + "self_avatar_loaded": classification == "ready", + }, + "appearance": self.appearance_attribution(classification), + } + for classification in classifications + ] + class FakeAPI: + def __init__(self, values: list[dict[str, object]]) -> None: + self.values = values + + def renderer_diagnostic_state(self) -> dict[str, object]: + return self.values.pop(0) + + api = FakeAPI(copy.deepcopy(diagnostics)) + samples = [collector.sample_appearance_attribution(api) for _ in diagnostics] + summary = collector.summarize_appearance_attribution( + [scene for scene, _ in samples], + [appearance for _, appearance in samples], + ) + self.assertIsNotNone(summary) + self.assertEqual("wearable-delivery-pending-or-failed", summary["classification"]) + + inconsistent = copy.deepcopy(diagnostics[-1]) + inconsistent["scene_state"]["self_avatar_loaded"] = False + with self.assertRaisesRegex(collector.ProtocolError, "does not match"): + collector.sample_appearance_attribution(FakeAPI([inconsistent])) + + def test_runner_rejects_zero_repeats(self) -> None: + parser = benchmark.build_parser() + with tempfile.TemporaryDirectory() as temp_name: + output_dir = Path(temp_name) / "results" + args = parser.parse_args([ + "run", + "--viewer", "/viewer/SecondLife", + "--manifest", str(PERF_DIR / "scenarios" / "steady-warm-v1.json"), + "--credential-file", str(Path(temp_name) / "does-not-exist"), + "--slurl", "secondlife://example/128/128/25", + "--hardware-label", "fixture-hardware", + *self.operator_args, + "--output-dir", str(output_dir), + "--repeats", "0", + "--dry-run", + ]) + with self.assertRaisesRegex(benchmark.BenchmarkError, "repeats must be positive"): + args.handler(args) + self.assertFalse(output_dir.exists()) + + def test_prime_only_rejects_a_cold_cache_manifest(self) -> None: + parser = benchmark.build_parser() + with tempfile.TemporaryDirectory() as temp_name: + output_dir = Path(temp_name) / "results" + args = parser.parse_args([ + "run", + "--viewer", "/viewer/SecondLife", + "--manifest", str(PERF_DIR / "scenarios" / "cold-streaming-v1.json"), + "--credential-file", str(Path(temp_name) / "does-not-exist"), + "--slurl", "secondlife://example/128/128/25", + "--hardware-label", "fixture-hardware", + *self.operator_args, + "--output-dir", str(output_dir), + "--prime-only", + "--dry-run", + ]) + with self.assertRaisesRegex(benchmark.BenchmarkError, "require a warm-cache manifest"): + args.handler(args) + self.assertFalse(output_dir.exists()) + + def test_multiple_warm_primes_require_prime_only_mode(self) -> None: + parser = benchmark.build_parser() + with tempfile.TemporaryDirectory() as temp_name: + output_dir = Path(temp_name) / "results" + args = parser.parse_args([ + "run", + "--viewer", "/viewer/SecondLife", + "--manifest", str(PERF_DIR / "scenarios" / "steady-warm-v1.json"), + "--credential-file", str(Path(temp_name) / "does-not-exist"), + "--slurl", "secondlife://example/128/128/25", + "--hardware-label", "fixture-hardware", + *self.operator_args, + "--output-dir", str(output_dir), + "--warm-prime-attempts", "2", + "--dry-run", + ]) + with self.assertRaisesRegex(benchmark.BenchmarkError, "require --prime-only"): + args.handler(args) + self.assertFalse(output_dir.exists()) + + def test_readiness_output_requires_two_shared_cache_launches(self) -> None: + parser = benchmark.build_parser() + with tempfile.TemporaryDirectory() as temp_name: + output_dir = Path(temp_name) / "results" + args = parser.parse_args([ + "run", + "--viewer", "/viewer/SecondLife", + "--manifest", str(PERF_DIR / "scenarios" / "steady-warm-v1.json"), + "--credential-file", str(Path(temp_name) / "does-not-exist"), + "--slurl", "secondlife://example/128/128/25", + "--hardware-label", "fixture-hardware", + *self.operator_args, + "--output-dir", str(output_dir), + "--prime-only", + "--readiness-output", str(Path(temp_name) / "readiness.json"), + "--dry-run", + ]) + with self.assertRaisesRegex(benchmark.BenchmarkError, "at least two warm primes"): + args.handler(args) + self.assertFalse(output_dir.exists()) + + def test_summary_includes_tail_and_resource_deltas(self) -> None: + summary = benchmark.summarize_result(self.fixture) + self.assertEqual(4, summary["sample_count"]) + self.assertEqual(11.5, summary["frame_time_ms"]["median"]) + self.assertEqual(30.0, summary["frame_time_ms"]["worst"]) + self.assertEqual(6144.0, summary["counter_deltas"]["texture_upload_bytes"]) + self.assertEqual(1.0, summary["counter_deltas"]["shader_compile_count"]) + + def test_result_schema_rejects_invalid_runs(self) -> None: + invalid = copy.deepcopy(self.fixture) + invalid["status"] = "invalid" + invalid["failure_reason"] = "asset load incomplete" + with self.assertRaisesRegex(benchmark.BenchmarkError, "asset load incomplete"): + benchmark.validate_result(invalid) + benchmark.validate_result(invalid, require_valid=False) + + def test_old_result_schema_is_rejected(self) -> None: + old = copy.deepcopy(self.fixture) + old["schema_version"] = 2 + with self.assertRaisesRegex(benchmark.BenchmarkError, "unsupported result schema 2"): + benchmark.validate_result(old) + + def test_result_requires_display_geometry(self) -> None: + missing = copy.deepcopy(self.fixture) + del missing["context"]["backing_scale_x"] + with self.assertRaisesRegex(benchmark.BenchmarkError, "backing_scale_x"): + benchmark.validate_result(missing) + + malformed = copy.deepcopy(self.fixture) + malformed["context"]["logical_width"] = "640" + with self.assertRaisesRegex(benchmark.BenchmarkError, "logical_width"): + benchmark.validate_result(malformed) + + def test_result_must_match_requested_settings_and_resolution(self) -> None: + manifest = benchmark.load_json(PERF_DIR / "scenarios" / "steady-warm-v1.json") + result = copy.deepcopy(self.fixture) + self.match_result_to_manifest(result, manifest) + result["context"]["effective_settings"] = copy.deepcopy(manifest["settings"]) + benchmark.validate_result_against_manifest(result, manifest) + + result["context"]["width"] = 1920 + with self.assertRaisesRegex(benchmark.BenchmarkError, "actual width=1920"): + benchmark.validate_result_against_manifest(result, manifest) + + result["context"]["width"] = manifest["settings"]["WindowWidth"] + result["context"]["effective_settings"]["RenderFarClip"] = 64.0 + with self.assertRaisesRegex(benchmark.BenchmarkError, "RenderFarClip=64.0"): + benchmark.validate_result_against_manifest(result, manifest) + + def test_diagnostic_validation_allows_only_failed_scene_gates(self) -> None: + manifest = benchmark.load_json(PERF_DIR / "scenarios" / "steady-warm-v1.json") + result = copy.deepcopy(self.fixture) + self.match_result_to_manifest(result, manifest) + result["context"]["effective_settings"] = copy.deepcopy(manifest["settings"]) + result["validity"]["observed"]["background_frame_count"] = 1 + result["validity"]["gates"] = benchmark.validity_gate_results( + result["validity"]["workload_id"], + result["validity"]["operator"], + result["validity"]["observed"], + result["validity"]["policy"], + ) + result["status"] = "invalid" + result["failure_reason"] = "scene validity gates failed: focus" + result.pop("summary") + benchmark.validate_result(result, require_valid=False) + benchmark.validate_result_against_manifest(result, manifest, require_all_gates=False) + with self.assertRaisesRegex(benchmark.BenchmarkError, "do not pass"): + benchmark.validate_result_against_manifest(result, manifest) + + def test_diagnostic_validation_still_requires_frame_samples(self) -> None: + invalid = copy.deepcopy(self.fixture) + invalid["status"] = "invalid" + invalid["failure_reason"] = "scene validity gates failed: assets" + invalid["frames"] = [] + with self.assertRaisesRegex(benchmark.BenchmarkError, "no numeric frame_time_ms"): + benchmark.validate_result(invalid, require_valid=False) + + def test_display_contract_accepts_1x_and_2x_geometry(self) -> None: + manifest = benchmark.load_json(PERF_DIR / "scenarios" / "steady-warm-v1.json") + retina = copy.deepcopy(self.fixture) + self.match_result_to_manifest(retina, manifest) + retina["context"]["effective_settings"] = copy.deepcopy(manifest["settings"]) + benchmark.validate_result_against_manifest(retina, manifest) + + standard = copy.deepcopy(retina) + standard["context"].update({ + "backing_scale_x": 1.0, + "backing_scale_y": 1.0, + "configured_ui_scale": 1.0, + "logical_width": 1280, + "logical_height": 720, + }) + benchmark.validate_result_against_manifest(standard, manifest) + + def test_display_contract_rejects_scale_and_geometry_mismatches(self) -> None: + manifest = benchmark.load_json(PERF_DIR / "scenarios" / "steady-warm-v1.json") + result = copy.deepcopy(self.fixture) + self.match_result_to_manifest(result, manifest) + result["context"]["effective_settings"] = copy.deepcopy(manifest["settings"]) + result["context"]["effective_display_scale_x"] = 2.0 + result["context"]["logical_height"] = 720 + with self.assertRaisesRegex(benchmark.BenchmarkError, "effective_display_scale_x"): + benchmark.validate_result_against_manifest(result, manifest) + with self.assertRaisesRegex(benchmark.BenchmarkError, "logical y geometry"): + benchmark.validate_result_against_manifest(result, manifest) + + def test_comparison_rejects_changed_context(self) -> None: + changed = copy.deepcopy(self.fixture) + changed["context"]["width"] = 1920 + changed["run"]["settings_hash"] = "different" + mismatches = benchmark.comparison_mismatches([self.fixture, changed]) + self.assertIn("context.width", mismatches) + self.assertIn("run.settings_hash", mismatches) + + changed_scale = copy.deepcopy(self.fixture) + changed_scale["context"]["backing_scale_x"] = 1.0 + mismatches = benchmark.comparison_mismatches([self.fixture, changed_scale]) + self.assertIn("context.backing_scale_x", mismatches) + + changed_workload = copy.deepcopy(self.fixture) + changed_workload["validity"]["workload_id"] = "another-steady-scene" + mismatches = benchmark.comparison_mismatches([self.fixture, changed_workload]) + self.assertIn("validity.workload_id", mismatches) + + def test_comparison_allows_extension_difference_between_backends_only(self) -> None: + zink = copy.deepcopy(self.fixture) + zink["context"]["backend_label"] = "zink" + zink["context"]["detected_backend"] = "zink" + zink["context"]["gl_extensions_hash"] = "zink-extensions" + self.assertEqual({}, benchmark.comparison_mismatches([self.fixture, zink])) + + changed_native = copy.deepcopy(self.fixture) + changed_native["context"]["gl_extensions_hash"] = "changed-native-extensions" + mismatches = benchmark.comparison_mismatches([self.fixture, changed_native]) + self.assertIn("context.gl_extensions_hash[native-gl]", mismatches) + + def test_report_applies_cross_backend_noise_threshold(self) -> None: + results = [] + for run_number in range(1, benchmark.MIN_COMPARISON_REPEATS + 1): + native = copy.deepcopy(self.fixture) + native["run"]["run_number"] = run_number + results.append(native) + zink = copy.deepcopy(self.fixture) + zink["run"]["run_number"] = run_number + zink["context"]["backend_label"] = "zink" + zink["context"]["detected_backend"] = "zink" + zink["context"]["gl_extensions_hash"] = "zink-extensions" + for frame in zink["frames"]: + frame["frame_time_ms"] += 5.0 + results.append(zink) + report = benchmark._format_markdown(results, None) + self.assertIn("Zink median run p95", report) + self.assertIn("meaningful", report) + + def test_report_requires_five_repeats_for_a_decision(self) -> None: + native = copy.deepcopy(self.fixture) + zink = copy.deepcopy(self.fixture) + zink["context"]["backend_label"] = "zink" + zink["context"]["detected_backend"] = "zink" + zink["context"]["gl_extensions_hash"] = "zink-extensions" + report = benchmark._format_markdown([native, zink], None) + self.assertIn("Decision is indeterminate", report) + self.assertIn("5 per backend are required", report) + + def test_sanitize_removes_private_fields_recursively(self) -> None: + unsafe = copy.deepcopy(self.fixture) + unsafe["context"]["machine_id"] = "machine-secret" + unsafe["run"]["username"] = "account-secret" + unsafe["frames"][0]["parcel"] = "private-place" + safe = benchmark.sanitize(unsafe) + serialized = json.dumps(safe) + self.assertNotIn("machine-secret", serialized) + self.assertNotIn("account-secret", serialized) + self.assertNotIn("private-place", serialized) + benchmark.validate_result(safe) + + def test_sanitize_removes_raw_destination_and_view(self) -> None: + unsafe = {"destination": "private-place", "view": {"origin": [1, 2, 3]}, "safe": True} + self.assertEqual({"safe": True}, benchmark.sanitize(unsafe)) + + def test_appearance_projection_is_fixed_and_fails_closed(self) -> None: + private_text = "secret account inventory and /private/path" + projected = benchmark.safe_appearance_attribution({ + "classification": private_text, + "avatar_valid": 1, + "cof_present": True, + "cof_complete": private_text, + "cof_change_in_progress": False, + "required_links_resolved": { + "shape": True, + "skin": private_text, + "extra-private-part": private_text, + }, + "required_wearables_delivered": private_text, + "avatar_loaded": True, + "raw_log": private_text, + }) + serialized = json.dumps(projected) + self.assertEqual("unknown", projected["classification"]) + self.assertFalse(projected["avatar_valid"]) + self.assertFalse(projected["cof_complete"]) + self.assertEqual( + {"shape": True, "skin": False, "hair": False, "eyes": False}, + projected["required_links_resolved"], + ) + self.assertNotIn(private_text, serialized) + self.assertNotIn("extra-private-part", serialized) + self.assertNotIn("raw_log", serialized) + + for classification in sorted(benchmark.APPEARANCE_CLASSIFICATIONS - {"unknown"}): + with self.subTest(classification=classification): + facts = self.appearance_attribution(classification) + self.assertEqual( + classification, + benchmark.safe_appearance_attribution(facts)["classification"], + ) + + contradictory = self.appearance_attribution("ready") + contradictory["classification"] = "cof-incomplete" + self.assertEqual( + "unknown", + benchmark.safe_appearance_attribution(contradictory)["classification"], + ) + malformed_classification = self.appearance_attribution() + malformed_classification["classification"] = [private_text] + self.assertEqual( + "unknown", + benchmark.safe_appearance_attribution(malformed_classification)["classification"], + ) + + def test_optional_appearance_does_not_change_gates_or_policy_hash(self) -> None: + result = copy.deepcopy(self.fixture) + original_gates = copy.deepcopy(result["validity"]["gates"]) + original_policy_hash = result["validity"]["policy_hash"] + result["validity"]["observed"]["appearance"] = self.appearance_attribution() + benchmark.validate_result(result) + self.assertEqual(original_gates, result["validity"]["gates"]) + self.assertEqual(original_policy_hash, result["validity"]["policy_hash"]) + + result["validity"]["observed"]["appearance"]["inventory_id"] = "private" + with self.assertRaisesRegex(benchmark.BenchmarkError, "privacy-safe contract"): + benchmark.validate_result(result) + + numeric_boolean = copy.deepcopy(self.fixture) + numeric_boolean["validity"]["observed"]["appearance"] = self.appearance_attribution( + "unknown" + ) + numeric_boolean["validity"]["observed"]["appearance"]["avatar_valid"] = 0 + with self.assertRaisesRegex(benchmark.BenchmarkError, "privacy-safe contract"): + benchmark.validate_result(numeric_boolean) + + def test_result_rejects_tampered_scene_gates(self) -> None: + tampered = copy.deepcopy(self.fixture) + tampered["validity"]["observed"]["destination_ready"] = False + with self.assertRaisesRegex(benchmark.BenchmarkError, "gates do not match"): + benchmark.validate_result(tampered) + + def test_scene_gate_failures_are_machine_checkable(self) -> None: + cases = { + "placement": ("destination_ready", False), + "focus": ("background_frame_count", 1), + "assets": ("texture_fetch_requests_max", 1), + "population": ("visible_avatars_max", 2), + "ui": ("modal_dialog_max", 1), + } + for expected_gate, (field, value) in cases.items(): + with self.subTest(gate=expected_gate): + observed = copy.deepcopy(self.fixture["validity"]["observed"]) + observed[field] = value + gates = benchmark.validity_gate_results( + self.fixture["validity"]["workload_id"], + self.fixture["validity"]["operator"], + observed, + self.fixture["validity"]["policy"], + ) + self.assertFalse(gates[expected_gate]) + + def test_scene_gate_rejects_missing_view_fingerprint(self) -> None: + observed = copy.deepcopy(self.fixture["validity"]["observed"]) + observed["view_hash"] = "" + gates = benchmark.validity_gate_results( + self.fixture["validity"]["workload_id"], + self.fixture["validity"]["operator"], + observed, + self.fixture["validity"]["policy"], + ) + self.assertFalse(gates["camera"]) + + def test_missing_observation_fails_standard_gates(self) -> None: + observed = copy.deepcopy(self.fixture["validity"]["observed"]) + del observed["view_hash"] + gates = benchmark.validity_gate_results( + self.fixture["validity"]["workload_id"], + self.fixture["validity"]["operator"], + observed, + self.fixture["validity"]["policy"], + ) + self.assertEqual(set(benchmark.VALIDITY_GATE_NAMES), set(gates)) + self.assertFalse(any(gates.values())) + + def test_rejection_message_extracts_only_known_failed_gates(self) -> None: + rejected = copy.deepcopy(self.fixture) + rejected["validity"]["gates"]["focus"] = False + rejected["validity"]["gates"]["private-location"] = False + self.assertEqual(["focus"], benchmark.failed_validity_gate_names(rejected)) + + def test_private_logs_reduce_to_readiness_categories(self) -> None: + with tempfile.TemporaryDirectory() as temp_name: + log_root = Path(temp_name) / "user" / "logs" + log_root.mkdir(parents=True) + (log_root / "SecondLife.log").write_text( + "private account and destination Failure in vf.write()\n" + "Self is clouded due to missing one or more required body parts: SHAPE\n", + encoding="utf-8", + ) + self.assertEqual( + "asset-cache-write", + benchmark._first_log_category(log_root, benchmark.CACHE_FAILURE_SIGNATURES), + ) + self.assertEqual( + "required-bodyparts-missing", + benchmark._first_log_category(log_root, benchmark.AVATAR_BLOCKER_SIGNATURES), + ) + + def test_readiness_does_not_reuse_a_failure_from_an_older_log(self) -> None: + with tempfile.TemporaryDirectory() as temp_name: + log_root = Path(temp_name) / "logs" + log_root.mkdir(parents=True) + viewer_log = log_root / "SecondLife.log" + viewer_log.write_text("Failure in vf.write()\n", encoding="utf-8") + cursor = benchmark._viewer_log_cursor(log_root) + with viewer_log.open("a", encoding="utf-8") as stream: + stream.write("clean second launch\n") + self.assertIsNone( + benchmark._first_log_category( + log_root, benchmark.CACHE_FAILURE_SIGNATURES, cursor + ) + ) + viewer_log.rename(log_root / "SecondLife.old") + viewer_log.write_text("clean rotated launch\n", encoding="utf-8") + self.assertIsNone( + benchmark._first_log_category( + log_root, benchmark.CACHE_FAILURE_SIGNATURES, cursor + ) + ) + with viewer_log.open("a", encoding="utf-8") as stream: + stream.write("Failure in vf.write()\n") + self.assertEqual( + "asset-cache-write", + benchmark._first_log_category( + log_root, benchmark.CACHE_FAILURE_SIGNATURES, cursor + ), + ) + + def test_readiness_report_contains_no_timing_or_private_log_text(self) -> None: + rejected = copy.deepcopy(self.fixture) + rejected["validity"]["observed"]["self_avatar_loaded"] = False + rejected["validity"]["observed"]["texture_fetch_requests_max"] = 1 + rejected["validity"]["gates"] = benchmark.validity_gate_results( + rejected["validity"]["workload_id"], + rejected["validity"]["operator"], + rejected["validity"]["observed"], + rejected["validity"]["policy"], + ) + with tempfile.TemporaryDirectory() as temp_name: + state = Path(temp_name) / "state" + benchmark.benchmark_state_settings({}, state, "warm") + log_root = state / "user" / "logs" + log_root.mkdir(parents=True) + (log_root / "SecondLife.log").write_text( + "secret-user at secret-place Failure in vf.write()\n" + "Self is clouded because lower textures not baked\n", + encoding="utf-8", + ) + before = benchmark.cache_lifecycle_facts(state) + attempt = benchmark.readiness_attempt(1, "rejected", rejected, before, state) + output = Path(temp_name) / "readiness.json" + benchmark.write_readiness_report(output, [attempt]) + report = benchmark.load_json(output) + serialized = json.dumps(report) + self.assertFalse(report["readiness_passed"]) + self.assertFalse(report["cache_reuse_passed"]) + self.assertFalse(report["all_scene_gates_passed"]) + self.assertEqual(["assets", "avatar"], report["target_gates"]) + self.assertFalse(report["retained_timing"]) + self.assertEqual(0, report["valid_measured_repeats"]) + self.assertNotIn("frames", serialized) + self.assertNotIn("summary", serialized) + self.assertNotIn("secret-user", serialized) + self.assertNotIn("secret-place", serialized) + self.assertNotIn(temp_name, serialized) + self.assertEqual( + "asset-cache-write", + report["attempts"][0]["first_cache_failure"], + ) + self.assertEqual( + {"assets": False, "avatar": False}, + report["attempts"][0]["target_gates"], + ) + self.assertFalse(report["attempts"][0]["assets"]["queues_settled"]) + self.assertEqual("lower-bake-missing", report["attempts"][0]["avatar"]["blocker"]) + + def test_readiness_report_projects_malformed_scalars_to_safe_types(self) -> None: + malformed = copy.deepcopy(self.fixture) + private_text = "private-account at /private/location from raw log" + malformed["validity"]["observed"]["texture_fetch_requests_max"] = private_text + malformed["validity"]["gates"]["assets"] = private_text + with tempfile.TemporaryDirectory() as temp_name: + state = Path(temp_name) / "state" + benchmark.benchmark_state_settings({}, state, "warm") + attempt = benchmark.readiness_attempt(1, "invalid-artifact", malformed, {}, state) + output = Path(temp_name) / "readiness.json" + benchmark.write_readiness_report(output, [attempt]) + report = benchmark.load_json(output) + serialized = json.dumps(report) + self.assertNotIn(private_text, serialized) + self.assertIsNone(report["attempts"][0]["target_gates"]["assets"]) + self.assertIsNone( + report["attempts"][0]["assets"]["observed"]["texture_fetch_requests_max"] + ) + + def test_readiness_prefers_fixed_appearance_attribution_over_log_text(self) -> None: + result = copy.deepcopy(self.fixture) + result["validity"]["observed"]["self_avatar_loaded"] = False + appearance = self.appearance_attribution("wearable-delivery-pending-or-failed") + appearance["required_wearables_delivered"]["hair"] = False + result["validity"]["observed"]["appearance"] = appearance + result["validity"]["gates"] = benchmark.validity_gate_results( + result["validity"]["workload_id"], + result["validity"]["operator"], + result["validity"]["observed"], + result["validity"]["policy"], + ) + with tempfile.TemporaryDirectory() as temp_name: + state = Path(temp_name) / "state" + benchmark.benchmark_state_settings({}, state, "warm") + log_root = state / "user" / "logs" + log_root.mkdir(parents=True) + (log_root / "SecondLife.log").write_text( + "Self is clouded because lower textures not baked\n", + encoding="utf-8", + ) + attempts = [ + benchmark.readiness_attempt(index, "rejected", result, {}, state) + for index in (1, 2) + ] + output = Path(temp_name) / "readiness.json" + benchmark.write_readiness_report(output, attempts) + report = benchmark.load_json(output) + self.assertEqual( + "wearable-delivery-pending-or-failed", + report["attempts"][0]["avatar"]["blocker"], + ) + self.assertEqual( + "wearable-delivery-pending-or-failed", + report["attempts"][1]["avatar"]["appearance"]["classification"], + ) + self.assertFalse(report["retained_timing"]) + self.assertEqual(0, report["valid_measured_repeats"]) + + def test_readiness_can_pass_while_other_scene_gates_remain_explicit(self) -> None: + rejected = copy.deepcopy(self.fixture) + rejected["validity"]["observed"]["background_frame_count"] = 1 + rejected["validity"]["gates"] = benchmark.validity_gate_results( + rejected["validity"]["workload_id"], + rejected["validity"]["operator"], + rejected["validity"]["observed"], + rejected["validity"]["policy"], + ) + with tempfile.TemporaryDirectory() as temp_name: + state = Path(temp_name) / "state" + benchmark.benchmark_state_settings({}, state, "warm") + before_first = benchmark.cache_lifecycle_facts(state) + asset_root = state / "cache" / "cache" + asset_root.mkdir() + (asset_root / "asset").write_bytes(b"cached") + first = benchmark.readiness_attempt( + 1, + "readiness-passed", + rejected, + before_first, + state, + prepare_reuse=True, + ) + self.assertEqual("ready", first["sentinel_install"]) + before_second = benchmark.cache_lifecycle_facts(state) + self.assertEqual("ready", before_second["requested_sentinel"]) + second = benchmark.readiness_attempt( + 2, "readiness-passed", rejected, before_second, state + ) + self.assertTrue(second["cache_ready"]) + output = Path(temp_name) / "readiness.json" + benchmark.write_readiness_report(output, [first, second]) + report = benchmark.load_json(output) + self.assertTrue(report["readiness_passed"]) + self.assertTrue(report["cache_reuse_passed"]) + self.assertFalse(report["all_scene_gates_passed"]) + self.assertEqual(["focus"], report["attempts"][1]["failed_gates"]) + + def test_rejected_first_prime_prepares_cache_reuse(self) -> None: + manifest = benchmark.load_json(PERF_DIR / "scenarios" / "steady-warm-v1.json") + results = [copy.deepcopy(self.fixture), copy.deepcopy(self.fixture)] + self.match_result_to_manifest(results[0], manifest) + self.match_result_to_manifest(results[1], manifest) + results[0]["validity"]["observed"]["texture_fetch_requests_max"] = 1 + results[0]["validity"]["observed"]["self_avatar_loaded"] = False + results[0]["validity"]["gates"] = benchmark.validity_gate_results( + results[0]["validity"]["workload_id"], + results[0]["validity"]["operator"], + results[0]["validity"]["observed"], + results[0]["validity"]["policy"], + ) + results[0]["status"] = "invalid" + results[0]["failure_reason"] = "scene validity gates failed: avatar, assets" + results[0].pop("summary") + + with tempfile.TemporaryDirectory() as temp_name: + readiness = Path(temp_name) / "readiness.json" + parser = benchmark.build_parser() + args = parser.parse_args([ + "run", + "--viewer", "/viewer/SecondLife", + "--manifest", str(PERF_DIR / "scenarios" / "steady-warm-v1.json"), + "--credential-file", str(Path(temp_name) / "account.txt"), + "--slurl", "secondlife://example/128/128/25", + "--hardware-label", "fixture-hardware", + *self.operator_args, + "--output-dir", str(Path(temp_name) / "results"), + "--warm-prime-attempts", "2", + "--prime-only", + "--readiness-output", str(readiness), + ]) + launch = 0 + + def fake_viewer_run(command: list[str], **kwargs: object) -> object: + nonlocal launch + launch += 1 + leap = json.loads(command[command.index("--leap") + 1]) + leap_args = benchmark.shlex.split(leap) + config = benchmark.load_json(Path(leap_args[leap_args.index("--config") + 1])) + self.assertTrue(config["appearance_diagnostics"]) + result = results[launch - 1] + result["context"]["effective_settings"] = copy.deepcopy( + config["requested_settings"] + ) + user_dir = Path(str(kwargs["env"]["SECONDLIFE_USER_DIR"])) + asset_root = user_dir.parent / "cache" / "cache" + asset_root.mkdir(parents=True, exist_ok=True) + (asset_root / "asset").write_bytes(b"cached") + Path(config["output"]).write_text(json.dumps(result), encoding="utf-8") + return benchmark.subprocess.CompletedProcess(command, 0) + + with ( + patch.object(benchmark, "_read_credentials", return_value=("first", "last", "pw")), + patch.object(benchmark, "_git_source", return_value={}), + patch.object(benchmark.subprocess, "run", side_effect=fake_viewer_run), + redirect_stdout(StringIO()), + ): + self.assertEqual(0, args.handler(args)) + + report = benchmark.load_json(readiness) + self.assertEqual(2, launch) + self.assertEqual(["rejected", "readiness-passed"], [ + attempt["outcome"] for attempt in report["attempts"] + ]) + self.assertEqual("ready", report["attempts"][0]["sentinel_install"]) + self.assertEqual("ready", report["attempts"][1]["cache_before_launch"]["requested_sentinel"]) + self.assertTrue(report["readiness_passed"]) + self.assertTrue(report["cache_reuse_passed"]) + self.assertEqual(0, report["valid_measured_repeats"]) + self.assertFalse(report["retained_timing"]) + + def test_prime_only_without_report_rejects_failed_cache_lifecycle(self) -> None: + manifest_path = PERF_DIR / "scenarios" / "steady-warm-v1.json" + manifest = benchmark.load_json(manifest_path) + result = copy.deepcopy(self.fixture) + self.match_result_to_manifest(result, manifest) + result["context"]["effective_settings"] = copy.deepcopy(manifest["settings"]) + + with tempfile.TemporaryDirectory() as temp_name: + parser = benchmark.build_parser() + args = parser.parse_args([ + "run", + "--viewer", "/viewer/SecondLife", + "--manifest", str(manifest_path), + "--credential-file", str(Path(temp_name) / "account.txt"), + "--slurl", "secondlife://example/128/128/25", + "--hardware-label", "fixture-hardware", + *self.operator_args, + "--output-dir", str(Path(temp_name) / "results"), + "--warm-prime-attempts", "2", + "--prime-only", + ]) + original_load_json = benchmark.load_json + + def load_fixture(path: Path) -> object: + return copy.deepcopy(result) if path.name == "warm-prime.json" else original_load_json(path) + + def fake_viewer_run(command: list[str], **_kwargs: object) -> object: + leap = json.loads(command[command.index("--leap") + 1]) + leap_args = benchmark.shlex.split(leap) + config = original_load_json(Path(leap_args[leap_args.index("--config") + 1])) + Path(config["output"]).write_text("{}", encoding="utf-8") + return benchmark.subprocess.CompletedProcess(command, 0) + + def fake_readiness_attempt(attempt: int, *_args: object, **_kwargs: object) -> dict[str, object]: + return {"cache_ready": attempt == 1, "outcome": "readiness-passed"} + + with ( + patch.object(benchmark, "_read_credentials", return_value=("first", "last", "pw")), + patch.object(benchmark, "_git_source", return_value={}), + patch.object(benchmark, "load_json", side_effect=load_fixture), + patch.object(benchmark, "cache_lifecycle_facts", return_value={}), + patch.object(benchmark, "readiness_attempt", side_effect=fake_readiness_attempt), + patch.object(benchmark.subprocess, "run", side_effect=fake_viewer_run), + redirect_stdout(StringIO()), + ): + with self.assertRaisesRegex(benchmark.BenchmarkError, "cache lifecycle check"): + args.handler(args) + + def test_readiness_rejects_fallback_cache_use(self) -> None: + with tempfile.TemporaryDirectory() as temp_name: + state = Path(temp_name) / "state" + benchmark.benchmark_state_settings({}, state, "warm") + (state / "cache" / "cache").mkdir() + self.assertEqual("ready", benchmark._install_cache_sentinel(state)) + (state / "user" / "cache" / "cache").mkdir(parents=True) + before = benchmark.cache_lifecycle_facts(state) + attempt = benchmark.readiness_attempt(2, "readiness-passed", self.fixture, before, state) + self.assertFalse(attempt["cache_ready"]) + self.assertIn( + "fallback-asset-root-present-before-reuse", attempt["cache_failures"] + ) + self.assertIn("fallback-asset-root-present-after", attempt["cache_failures"]) + + def test_readiness_explains_target_gate_failures_without_timing(self) -> None: + result = copy.deepcopy(self.fixture) + result["validity"]["observed"]["agent_travel_m"] = 1.0 + result["validity"]["observed"]["settle_seconds_observed"] = 0.0 + result["validity"]["gates"] = benchmark.validity_gate_results( + result["validity"]["workload_id"], + result["validity"]["operator"], + result["validity"]["observed"], + result["validity"]["policy"], + ) + with tempfile.TemporaryDirectory() as temp_name: + state = Path(temp_name) / "state" + benchmark.benchmark_state_settings({}, state, "warm") + (state / "cache" / "cache").mkdir() + attempt = benchmark.readiness_attempt(1, "rejected", result, {}, state) + self.assertFalse(attempt["target_gates"]["assets"]) + self.assertFalse(attempt["assets"]["settlement_complete"]) + self.assertTrue(attempt["assets"]["queues_settled"]) + self.assertFalse(attempt["target_gates"]["avatar"]) + self.assertFalse(attempt["avatar"]["stationary"]) + self.assertEqual("avatar-moved", attempt["avatar"]["blocker"]) + + def test_scene_summary_contains_only_relative_view_fingerprint(self) -> None: + state = { + "destination_matches": True, + "teleport_in_progress": False, + "progress_visible": False, + "app_focused": True, + "frame_count_total": 100, + "foreground_frame_count_total": 100, + "camera_animating": False, + "agent_distance_traveled_total": 5.0, + "agent_speed_mps": 0.0, + "view": {"camera_offset": [1.0, 2.0, 3.0], "mode": 1}, + "modal_dialog_count": 0, + "alert_toast_visible": False, + "welcome_pack_visible": False, + "hint_visible": False, + "closeable_floaters_closed": True, + "texture_fetch_requests": 0, + "texture_http_requests": 0, + "texture_create_queue": 0, + "texture_fast_cache": 0, + "texture_upload_count_total": 10, + "mesh_lod_unresolved": 0, + "mesh_skin_unresolved": 0, + "self_avatar_loaded": True, + "visible_avatars": 1, + "active_objects": 100, + "circuit_present": True, + "circuit_alive": True, + "circuit_blocked": False, + "pings_in_transit": 0, + "packets_in_total": 100, + "packets_lost_total": 0, + } + final = copy.deepcopy(state) + final["frame_count_total"] = 110 + final["foreground_frame_count_total"] = 110 + final["packets_in_total"] = 120 + observed = collector.summarize_scene_validity( + self.fixture["validity"]["policy"], + [state, final], + [state, final], + [{"sim_ping_ms": 40, "new_objects": 0}], + 15.0, + ) + self.assertRegex(observed["view_hash"], r"^[a-f0-9]{64}$") + self.assertNotIn("view", observed) + self.assertNotIn("destination", observed) + + def test_checked_in_fixture_is_valid_and_private(self) -> None: + benchmark.validate_result(self.fixture) + self.assertEqual([], benchmark.find_private_paths(self.fixture)) + self.assertEqual(self.fixture["summary"], benchmark.summarize_result(self.fixture)) + + def test_dry_run_does_not_open_credentials_and_redacts_login(self) -> None: + parser = benchmark.build_parser() + with tempfile.TemporaryDirectory() as temp_name: + args = parser.parse_args([ + "run", + "--viewer", "/viewer/SecondLife", + "--manifest", str(PERF_DIR / "scenarios" / "steady-warm-v1.json"), + "--credential-file", str(Path(temp_name) / "does-not-exist"), + "--slurl", "secondlife://example/128/128/25", + "--hardware-label", "fixture-hardware", + *self.operator_args, + "--output-dir", temp_name, + "--repeats", "1", + "--dry-run", + ]) + output = StringIO() + with redirect_stdout(output): + self.assertEqual(0, args.handler(args)) + rendered = output.getvalue() + self.assertIn("", rendered) + self.assertNotIn("does-not-exist", rendered) + self.assertNotIn("secondlife://example/128/128/25", rendered) + + def test_prime_only_dry_run_has_no_measured_repeat(self) -> None: + parser = benchmark.build_parser() + with tempfile.TemporaryDirectory() as temp_name: + readiness = Path(temp_name) / "readiness.json" + args = parser.parse_args([ + "run", + "--viewer", "/viewer/SecondLife", + "--manifest", str(PERF_DIR / "scenarios" / "steady-warm-v1.json"), + "--credential-file", str(Path(temp_name) / "does-not-exist"), + "--slurl", "secondlife://example/128/128/25", + "--hardware-label", "fixture-hardware", + *self.operator_args, + "--output-dir", str(Path(temp_name) / "results"), + "--warm-prime-attempts", "2", + "--prime-only", + "--readiness-output", str(readiness), + "--dry-run", + ]) + output = StringIO() + with redirect_stdout(output): + self.assertEqual(0, args.handler(args)) + rendered = output.getvalue() + self.assertEqual(2, rendered.count("--login")) + self.assertNotIn("run-01", rendered) + self.assertFalse(readiness.exists()) + + def test_normal_benchmark_path_does_not_probe_the_cache(self) -> None: + parser = benchmark.build_parser() + with tempfile.TemporaryDirectory() as temp_name: + args = parser.parse_args([ + "run", + "--viewer", "/viewer/SecondLife", + "--manifest", str(PERF_DIR / "scenarios" / "steady-warm-v1.json"), + "--credential-file", str(Path(temp_name) / "does-not-exist"), + "--slurl", "secondlife://example/128/128/25", + "--hardware-label", "fixture-hardware", + *self.operator_args, + "--output-dir", str(Path(temp_name) / "results"), + "--repeats", "1", + "--dry-run", + ]) + with patch.object( + benchmark, + "cache_lifecycle_facts", + side_effect=AssertionError("measurement path probed the cache"), + ): + with redirect_stdout(StringIO()): + self.assertEqual(0, args.handler(args)) + + def test_normal_warm_launches_disable_appearance_diagnostics(self) -> None: + manifest_path = PERF_DIR / "scenarios" / "steady-warm-v1.json" + manifest = benchmark.load_json(manifest_path) + result = copy.deepcopy(self.fixture) + self.match_result_to_manifest(result, manifest) + flags: list[bool] = [] + + with tempfile.TemporaryDirectory() as temp_name: + parser = benchmark.build_parser() + args = parser.parse_args([ + "run", + "--viewer", "/viewer/SecondLife", + "--manifest", str(manifest_path), + "--credential-file", str(Path(temp_name) / "account.txt"), + "--slurl", "secondlife://example/128/128/25", + "--hardware-label", "fixture-hardware", + *self.operator_args, + "--output-dir", str(Path(temp_name) / "results"), + "--repeats", "1", + ]) + + def fake_viewer_run(command: list[str], **_kwargs: object) -> object: + leap = json.loads(command[command.index("--leap") + 1]) + leap_args = benchmark.shlex.split(leap) + config = benchmark.load_json(Path(leap_args[leap_args.index("--config") + 1])) + flags.append(config["appearance_diagnostics"]) + launch_result = copy.deepcopy(result) + launch_result["context"]["effective_settings"] = copy.deepcopy( + config["requested_settings"] + ) + Path(config["output"]).write_text(json.dumps(launch_result), encoding="utf-8") + return benchmark.subprocess.CompletedProcess(command, 0) + + with ( + patch.object(benchmark, "_read_credentials", return_value=("first", "last", "pw")), + patch.object(benchmark, "_git_source", return_value={}), + patch.object(benchmark.subprocess, "run", side_effect=fake_viewer_run), + redirect_stdout(StringIO()), + ): + self.assertEqual(0, args.handler(args)) + + self.assertEqual([False, False], flags) + + @unittest.skipIf(os.name == "nt", "POSIX credential modes do not apply on Windows") + def test_credentials_must_be_private(self) -> None: + with tempfile.TemporaryDirectory() as temp_name: + credential = Path(temp_name) / "account.txt" + credential.write_text("first last password\n", encoding="utf-8") + credential.chmod(0o644) + with self.assertRaisesRegex(benchmark.BenchmarkError, "group or other"): + benchmark._read_credentials(credential) + credential.chmod(0o600) + self.assertEqual(("first", "last", "password"), benchmark._read_credentials(credential)) + + +if __name__ == "__main__": + unittest.main()