Source Code
src/pages/projects/gpu_shaders.rs
use leptos::*;
use leptos_meta::{Meta, Title};
use super::data::ALL_PROJECTS;
#[component]
pub fn GpuShaders() -> impl IntoView {
let project = ALL_PROJECTS
.iter()
.find(|p| p.slug == "gpu-shaders")
.unwrap();
let skills_view = project
.skills
.iter()
.map(|&s| view! { <li>{s}</li> })
.collect::<Vec<_>>();
view! {
<Title text="GPU Shaders – Peter Pinto"/>
<Meta name="description" content="SPIR-V GPU shaders written in Rust via Rust-GPU, implementing H3 hexagonal cell boundary generation entirely on the GPU."/>
<div class="page">
<span class="eyebrow">"Projects"</span>
<h1>"GPU " <em style="font-style:italic; color: var(--accent)">"Shaders"</em></h1>
<p class="lead">
"SPIR-V shaders written entirely in Rust using Rust-GPU. The vertex shader
computes H3 hexagonal cell boundaries on the GPU — no mesh data uploaded
from the CPU."
</p>
<ul class="skills-list" style="margin-top: 1.5rem;">
{skills_view}
</ul>
<hr class="divider"/>
// ── Rust-GPU ──────────────────────────────────────────
<section class="project-section">
<span class="eyebrow">"Toolchain"</span>
<h2>"Shaders in Rust"</h2>
<p>
<a href="https://github.com/Rust-GPU/Rust-GPU" target="_blank" rel="noopener noreferrer" class="prose-link">"Rust-GPU"</a>
" is a compiler backend that emits SPIR-V bytecode from Rust source,
replacing GLSL/HLSL entirely. Shader code lives in a regular Rust crate
with "
<code class="inline-code">"#![no_std]"</code>
" and "
<code class="inline-code">"#![no_main]"</code>
" — entry points are ordinary functions annotated with "
<code class="inline-code">"#[spirv(vertex)]"</code>
" and "
<code class="inline-code">"#[spirv(fragment)]"</code>
". The same Rust type system, borrow checker, and toolchain that
governs the rest of the project also governs the shaders."
</p>
</section>
<hr class="divider"/>
// ── GPU-Side H3 ───────────────────────────────────────
<section class="project-section">
<span class="eyebrow">"Algorithm"</span>
<h2>"H3 on the GPU"</h2>
<p>
"The vertex shader takes two per-vertex inputs: a "
<code class="inline-code">"cell_index"</code>
" (an H3 64-bit cell identifier) and a "
<code class="inline-code">"vertex_index"</code>
" (0–9, one per boundary point). Using "
<code class="inline-code">"h3o_noalloc"</code>
" — a no-allocation H3 port designed for constrained environments —
it looks up the cell's geographic boundary on the GPU, converts the
lat/lng coordinates to a 3D point on the unit sphere, and outputs the
clip-space position."
</p>
<p style="margin-top: 1rem;">
"The key benefit: the CPU only uploads a list of H3 cell indices.
All geometry is generated in the shader — no mesh serialisation,
no vertex buffer construction, no round-trips."
</p>
</section>
<hr class="divider"/>
// ── h3o_noalloc ───────────────────────────────────────
<section class="project-section">
<span class="eyebrow">"Dependency"</span>
<h2>"Forking h3o for the GPU"</h2>
<p>
"The upstream "
<code class="inline-code">"h3o"</code>
" crate is a complete Rust implementation of the H3 geospatial indexing
system. In a normal context it works well, but its geometry APIs
— polygon tiling, dissolving cell sets into polygons, vertex graph
operations — depend on "
<code class="inline-code">"Vec"</code>
", "
<code class="inline-code">"HashSet"</code>
", and "
<code class="inline-code">"HashMap"</code>
". SPIR-V has no heap allocator, so those types don't exist. The entire
"
<code class="inline-code">"geom"</code>
" module had to be excised."
</p>
<p style="margin-top: 1rem;">
"The resulting fork, "
<code class="inline-code">"h3o_noalloc"</code>
", keeps all of the core indexing and boundary logic and replaces every
dynamic collection with a fixed-size equivalent:"
</p>
<ol class="project-steps">
<li>
<strong>"Boundary vertices"</strong>
" — changed from a "
<code class="inline-code">"Vec<LatLng>"</code>
" to a "
<code class="inline-code">"[LatLng; 10]"</code>
" with a "
<code class="inline-code">"u8"</code>
" count field. Ten is the maximum possible boundary vertices for any
H3 cell (a pentagon crossing an icosahedron edge), so the size is
exact and provable at compile time."
</li>
<li>
<strong>"Face sets"</strong>
" — the set of icosahedron faces a cell spans was stored as a "
<code class="inline-code">"HashSet"</code>
". Replaced with "
<code class="inline-code">"FaceSet(u32)"</code>
", a 20-bit bitmap — one bit per face. Insert, contains, and iterate
are all bitwise operations on a single register."
</li>
<li>
<strong>"Children iterators"</strong>
" — grid traversal iterators that previously built intermediate
"
<code class="inline-code">"Vec"</code>
"s now carry a "
<code class="inline-code">"u64"</code>
" scratchpad. All state fits in a single integer."
</li>
</ol>
<p style="margin-top: 1rem;">
"The geometry APIs ("
<code class="inline-code">"Tiler"</code>
", "
<code class="inline-code">"Solvent"</code>
", "
<code class="inline-code">"Plotter"</code>
") are feature-gated behind "
<code class="inline-code">"feature = \"geom\""</code>
" and require "
<code class="inline-code">"std"</code>
". The shader crate never enables that feature."
</p>
</section>
<hr class="divider"/>
// ── Floating Point ────────────────────────────────────
<section class="project-section">
<span class="eyebrow">"Challenge"</span>
<h2>"Floating-Point on the GPU"</h2>
<p>
"H3's boundary computation is built on "
<code class="inline-code">"f64"</code>
" trigonometry — "
<code class="inline-code">"sin"</code>
", "
<code class="inline-code">"cos"</code>
", "
<code class="inline-code">"atan2"</code>
", "
<code class="inline-code">"asin"</code>
". The Vulkan GLSL.std.450 extended instruction set only supports
16- and 32-bit floats; 64-bit transcendentals are not available."
</p>
<p style="margin-top: 1rem;">
"The fix is a dedicated "
<code class="inline-code">"math-spirv.rs"</code>
" module, selected at compile time via "
<code class="inline-code">"#[cfg(target_arch = \"spirv\")]"</code>
". Every transcendental casts down to "
<code class="inline-code">"f32"</code>
", calls the SPIR-V intrinsic, then casts back:"
</p>
<div class="code-block" style="margin-top: 1.25rem;">
<pre><code>"fn sin(x: f64) -> f64 { (x as f32).sin() as f64 }
fn atan2(y: f64, x: f64) -> f64 { (y as f32).atan2(x as f32) as f64 }"</code></pre>
</div>
<p style="margin-top: 1rem;">
"The precision loss from the "
<code class="inline-code">"f64 → f32 → f64"</code>
" round-trip is acceptable for rendering cell outlines — H3 cell
boundaries are defined in integer coordinates and only converted to
lat/lng at the last step — but would be problematic for indexing
operations that rely on exact integer arithmetic. Those stay in integer
space throughout. The three math backends ("
<code class="inline-code">"math-std.rs"</code>
", "
<code class="inline-code">"math-libm.rs"</code>
", "
<code class="inline-code">"math-spirv.rs"</code>
") are otherwise identical in interface, so the same h3o_noalloc code
compiles for CPU tests and GPU production without any conditional logic
in the algorithm itself."
</p>
</section>
<hr class="divider"/>
// ── Error Handling ────────────────────────────────────
<section class="project-section">
<span class="eyebrow">"Challenge"</span>
<h2>"Error Handling Without std"</h2>
<p>
"Rust's "
<code class="inline-code">"std::error::Error"</code>
" trait and "
<code class="inline-code">"Display"</code>
" are not available in "
<code class="inline-code">"no_std"</code>
" SPIR-V targets. h3o's error types implement both. The fork gates those
trait implementations behind "
<code class="inline-code">"#[cfg(not(target_arch = \"spirv\"))]"</code>
" — the error "
<em>"types"</em>
" still exist and "
<code class="inline-code">"Result"</code>
" still propagates correctly inside the shader, but they carry no
human-readable message. This keeps the full "
<code class="inline-code">"Result"</code>
"-based API intact for CPU unit tests while satisfying the GPU
constraints."
</p>
</section>
<hr class="divider"/>
// ── Build ─────────────────────────────────────────────
<section class="project-section">
<span class="eyebrow">"Build"</span>
<h2>"cargo gpu"</h2>
<p>
"The shader crate is compiled with "
<code class="inline-code">"cargo gpu"</code>
", the Rust-GPU build driver, which invokes the SPIR-V backend and
produces a "
<code class="inline-code">".spv"</code>
" binary ready to be loaded by Bevy's asset pipeline at runtime."
</p>
<div class="code-block" style="margin-top: 1.25rem;">
<pre><code>"cargo gpu build --package shaders"</code></pre>
</div>
</section>
<hr class="divider"/>
// ── Status ────────────────────────────────────────────
<section class="project-section">
<span class="eyebrow">"Status"</span>
<h2>"Proof of Concept"</h2>
<p>
"The vertex and fragment shaders compile and produce correct cell
boundaries on screen. The current fragment shader outputs a flat colour;
simulation state visualisation — colouring cells by biome, population
density, or temperature — is the intended next step once the simulation
data pipeline is in place."
</p>
</section>
</div>
}
}