git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commitcef10a575724fc56d475723b661e8fd4548ea2a4
parentd8bf03bfbb
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-05 21:41
perf(vk): compile the renderer's fixed shaders to SPIR-V at build time

Every process that built a VkRenderer ran naga's WGSL front end,
validator and SPIR-V writer over shader2d, glyph, scene3d and
scene3d_image: 20-60 ms of each app's launch, nearly all of it
shader2d.wgsl's 1,300 lines. The output is a pure function of the
source, so build.rs now makes it once and the *_spirv() fns embed it
(include_bytes! from OUT_DIR, unpacked to words once per process). A
WGSL error in those four is now a build failure, not a panic at the
first window. compile_wgsl stays for the shaders built at run time.

precompiled_spirv_matches_runtime_compile compares build.rs's output
with compile_wgsl's word for word, so the two sets of naga options
cannot drift apart unnoticed.

Measured in a headless shadow, cce-cloud standalone (a fresh renderer
per process, as in any app), 9 runs each under load average 33-53:
renderer setup median 79 -> 61 ms, window setup 149 -> 141 ms.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 Cargo.toml         |  6 ++++++
 build.rs           | 51 ++++++++++++++++++++++++++++++++++++++++++++++
 src/vk/mod.rs      |  4 ++--
 src/vk/renderer.rs | 59 ++++++++++++++++++++++++++++++++++++------------------
 4 files changed, 98 insertions(+), 22 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index 6aacdfb..acb51d7 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -224,3 +224,9 @@ crate-type = ["cdylib"]
 [[example]]
 name = "demo_web"
 crate-type = ["cdylib"]
+
+[build-dependencies]
+# build.rs compiles the fixed WGSL shaders to SPIR-V once, instead of in every
+# process that builds a renderer. Same version as the runtime dependency above,
+# which still compiles the shaders made at run time.
+naga = { version = "24", features = ["wgsl-in", "spv-out"] }
diff --git a/build.rs b/build.rs
new file mode 100644
index 0000000..c94eb04
--- /dev/null
+++ b/build.rs
@@ -0,0 +1,51 @@
+//! Compiles the renderer's fixed WGSL shaders to SPIR-V at build time.
+//!
+//! Every `VkRenderer` needs these four, and naga's WGSL front end, validator
+//! and SPIR-V writer ran in every process that built one: 20-60 ms of each
+//! app's launch, nearly all of it `shader2d.wgsl`. The output is a pure
+//! function of the source, so it is made once here and embedded
+//! (`renderer::shader2d_spirv` and friends). A WGSL error is now a build
+//! failure rather than a panic at the first window.
+//!
+//! The sources are `draw::shaders`' files, which the Vulkan path uses
+//! verbatim (the WebGPU and Metal backends make their own variants of them).
+//! The options MUST match `renderer::compile_wgsl`, which still compiles the
+//! shaders built at run time (compute kernels, the ray-query stack);
+//! `precompiled_spirv_matches_runtime_compile` checks that they do.
+
+use std::path::Path;
+
+const SHADERS: [&str; 4] = ["shader2d", "glyph", "scene3d", "scene3d_image"];
+
+fn main() {
+    let out_dir = std::env::var("OUT_DIR").expect("OUT_DIR");
+    for name in SHADERS {
+        let src_path = format!("src/draw/{name}.wgsl");
+        println!("cargo:rerun-if-changed={src_path}");
+        let source = std::fs::read_to_string(&src_path)
+            .unwrap_or_else(|e| panic!("read {src_path}: {e}"));
+        let spirv = compile_wgsl(&source, &src_path);
+        let bytes: Vec<u8> = spirv.iter().flat_map(|w| w.to_le_bytes()).collect();
+        std::fs::write(Path::new(&out_dir).join(format!("{name}.spv")), bytes)
+            .unwrap_or_else(|e| panic!("write {name}.spv: {e}"));
+    }
+    println!("cargo:rerun-if-changed=build.rs");
+}
+
+fn compile_wgsl(source: &str, path: &str) -> Vec<u32> {
+    let module = naga::front::wgsl::parse_str(source)
+        .unwrap_or_else(|e| panic!("{path}: WGSL parse failed:\n{}", e.emit_to_string(source)));
+    let info = naga::valid::Validator::new(
+        naga::valid::ValidationFlags::all(),
+        naga::valid::Capabilities::PUSH_CONSTANT,
+    )
+    .validate(&module)
+    .unwrap_or_else(|e| panic!("{path}: WGSL validation failed:\n{}", e.emit_to_string(source)));
+    let options = naga::back::spv::Options {
+        lang_version: (1, 0),
+        flags: naga::back::spv::WriterFlags::LABEL_VARYINGS,
+        ..Default::default()
+    };
+    naga::back::spv::write_vec(&module, &info, &options, None)
+        .unwrap_or_else(|e| panic!("{path}: SPIR-V write failed: {e}"))
+}
diff --git a/src/vk/mod.rs b/src/vk/mod.rs
index 769e5a0..ae5f3dc 100644
--- a/src/vk/mod.rs
+++ b/src/vk/mod.rs
@@ -62,8 +62,8 @@ pub use text::TextSpan;
 
 /// Pay the process-wide, window-independent renderer costs up front: the
 /// shared Vulkan instance (ICD enumeration + driver init), one throwaway
-/// device (loads the driver's device-level libraries), and the naga WGSL
-/// compiles. For daemon-style processes (cce-cloud) that build a renderer per
+/// device (loads the driver's device-level libraries), and unpacking the
+/// shaders' SPIR-V (compiled by build.rs; naga no longer runs here). For daemon-style processes (cce-cloud) that build a renderer per
 /// window: called at daemon startup, it moves the multi-second cold-cache hit
 /// off the first window's critical path.
 pub fn prewarm() {
diff --git a/src/vk/renderer.rs b/src/vk/renderer.rs
index 131ec2b..c59687d 100644
--- a/src/vk/renderer.rs
+++ b/src/vk/renderer.rs
@@ -435,28 +435,28 @@ pub(crate) fn compile_wgsl(source: &str) -> Vec<u32> {
     naga::back::spv::write_vec(&module, &info, &options, None).expect("SPIR-V write failed")
 }
 
-/// Cached SPIR-V for the always-compiled UI shaders. The daemon-style
-/// consumers (cce-cloud) build a renderer per popup; naga compilation is pure,
-/// so compile each shader once per process.
-pub(crate) fn shader2d_spirv() -> &'static [u32] {
-    static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
-    SPIRV.get_or_init(|| compile_wgsl(crate::draw::shaders::SHADER2D))
-}
-
-pub(crate) fn glyph_spirv() -> &'static [u32] {
-    static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
-    SPIRV.get_or_init(|| compile_wgsl(crate::draw::shaders::GLYPH))
-}
-
-pub(crate) fn scene3d_spirv() -> &'static [u32] {
-    static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
-    SPIRV.get_or_init(|| compile_wgsl(crate::draw::shaders::SCENE3D))
+/// SPIR-V for the renderer's fixed shaders, compiled from their WGSL by
+/// `build.rs` and embedded. naga used to compile them in every process that
+/// built a renderer, 20-60 ms of each app's launch (nearly all shader2d).
+/// Only the byte-to-word conversion runs here, once per process.
+macro_rules! precompiled_spirv {
+    ($name:ident, $file:literal) => {
+        pub(crate) fn $name() -> &'static [u32] {
+            static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
+            SPIRV.get_or_init(|| {
+                include_bytes!(concat!(env!("OUT_DIR"), "/", $file))
+                    .chunks_exact(4)
+                    .map(|w| u32::from_le_bytes([w[0], w[1], w[2], w[3]]))
+                    .collect()
+            })
+        }
+    };
 }
 
-pub(crate) fn scene3d_image_spirv() -> &'static [u32] {
-    static SPIRV: std::sync::OnceLock<Vec<u32>> = std::sync::OnceLock::new();
-    SPIRV.get_or_init(|| compile_wgsl(crate::draw::shaders::SCENE3D_IMAGE))
-}
+precompiled_spirv!(shader2d_spirv, "shader2d.spv");
+precompiled_spirv!(glyph_spirv, "glyph.spv");
+precompiled_spirv!(scene3d_spirv, "scene3d.spv");
+precompiled_spirv!(scene3d_image_spirv, "scene3d_image.spv");
 
 /// Like [`compile_wgsl`], but with naga's RAY_QUERY capability and SPIR-V 1.4
 /// (required by SPV_KHR_ray_query). Only used on devices where the ray-query
@@ -2541,6 +2541,25 @@ mod tests {
     ///
     /// Reads the struct out of the shader source rather than duplicating its
     /// shape here, so it measures the thing it is guarding.
+    /// build.rs compiles the fixed shaders with its own copy of
+    /// `compile_wgsl`'s options. If the two drift, the embedded SPIR-V is no
+    /// longer what this crate means by the shader; compare word for word.
+    #[test]
+    fn precompiled_spirv_matches_runtime_compile() {
+        let cases: [(&str, &str, fn() -> &'static [u32]); 4] = [
+            ("shader2d", crate::draw::shaders::SHADER2D, super::shader2d_spirv),
+            ("glyph", crate::draw::shaders::GLYPH, super::glyph_spirv),
+            ("scene3d", crate::draw::shaders::SCENE3D, super::scene3d_spirv),
+            ("scene3d_image", crate::draw::shaders::SCENE3D_IMAGE, super::scene3d_image_spirv),
+        ];
+        for (name, source, precompiled) in cases {
+            assert!(
+                super::compile_wgsl(source) == precompiled(),
+                "{name}: build.rs output differs from compile_wgsl"
+            );
+        }
+    }
+
     #[test]
     fn window_info_layout_matches_the_uniform_size() {
         let src = crate::draw::shaders::SHADER2D;