Akari (src/akari/) owns the shader product path end-to-end: pure package SoT
under shaders/akari/packages/*.akari, shared modules under
shaders/akari/modules/*.akari, transpile → MSL/HLSL (bindings resolved at emit),
platform compile (xcrun metal / DXC / DXC -spirv). Metal metalc uses -std=metal4.0 and
-mmacosx-version-min=26.0. Specs: build.akari.metal.json /
build.akari.d3d12.json / build.akari.vulkan.json (type KhronosVulkan; same
package list as the other two). Vulkan compiles through the vendored DXC in
SPIR-V mode, so it cooks on any host — no Vulkan SDK.
Language reference (all syntax and features): Akari language. Grammar: src/akari/syntax.ebnf.
Editor highlighting: tools/vscode-extensions/akari (VS Code / Cursor); tools/nvim-plugins/akari (Neovim / Vim).
Tool notes: src/akari/AGENTS.md (Rust edition 2024). Product binary: flat bin/akari/akari (not platform-arch). Shinra invokes via $AKARI_SC / --akari-sc. ABI packing: src/akari/docs/abi.md.
There is no dual-native metal/ / d3d12/ / *_includes/ authoring tree.
Ray tracing uses first-class intrinsics (rt_visibility, rt_trace, bindless loads).
One authored package feeds each target's compiler:
Diagram source
flowchart TD
source["Akari packages + shared modules"] --> akari["Akari — validate and emit resolved bindings"]
spec["Target build specification"] --> akari
akari --> msl["MSL"]
akari --> hlsl["HLSL"]
msl --> metal["Metal compiler — Metal 4 / macOS 26"]
hlsl --> dxc["DXC — D3D12 target"]
hlsl --> spirv["DXC -spirv — Vulkan target"]
metal --> metalout["Metal shader artifacts"]
dxc --> dxout["D3D12 shader artifacts"]
spirv --> vkout["SPIR-V shader artifacts"]Pass roles and packages
One package ≈ one pass role. Materials pick a package; they do not invent keyword sets. Graph pass order and gates: Rendering — Implemented pipeline.
| Role | Package | PipelineKind | Layout |
|---|---|---|---|
| Opaque deferred geometry | _engine/gbuffer (or game package) | gbuffer_geometry | standard |
| Soft-ref / missing material | _engine/error_material | gbuffer_geometry | standard |
| Transparent forward | _engine/forward_lit | forward_transparent / forward_transparent_hdr | forward_lit |
| Shadow depth | _engine/shadow_depth | shadow_depth | standard |
| Shadow depth, alpha-cut casters | _engine/shadow_depth_masked | shadow_depth (same state; the material carries the PS) | standard |
| Deferred lighting | _engine/deferred_light | lighting / hdr_lighting | fullscreen_shadowed |
| Hybrid-shadow lighting | _engine/hybrid_light | lighting / hdr_lighting | fullscreen_hybrid_shadowed |
| RT-shadow lighting | _engine/raytraced_light | lighting / hdr_lighting | fullscreen_raytraced |
| Screen-space AO | _engine/screen_space_ambient_occlusion_compute | ambient_occlusion | fullscreen_reflections |
| Ray-traced AO | _engine/ray_traced_ambient_occlusion | ambient_occlusion | fullscreen_raytraced |
| Screen-space reflections | _engine/screen_space_reflections | hdr_lighting | fullscreen_reflections |
| Ray-traced reflections | _engine/ray_traced_reflections | hdr_lighting | fullscreen_raytraced |
| TAA resolve | _engine/temporal | hdr_lighting | fullscreen_reflections |
| Tonemap (ACES) | _engine/tonemap | tonemap (TONEMAP=aces) | fullscreen_texture_table |
| Tonemap (Reinhard) | _engine/tonemap_reinhard | tonemap (TONEMAP=reinhard, same source) | fullscreen_texture_table |
| Editor viewport blit | _engine/composite | tonemap | fullscreen_texture_table |
| Debug G-buffer grid | _engine/debug_visualizers | debug_visualizers | debug_visualizers |
| UI | _engine/ui | ui | ui / ui_atlas |
| Camera meshlet cull (compute) | _engine/meshlet_cull | (compute PSO, not mesh PipelineKind) | standard |
| RT opacity micromap bake (compute) | _engine/opacity_micromap_bake | (lazy renderer-owned compute PSO) | standard |
Other engine packages (compute / post, not material roles) live beside these in src/hikari/shaders/akari/packages/: bloom, exposure, depth of field, motion blur, auto-focus, GI (screen-space, ray-traced, temporal/spatial/bounce), particles, volumetrics, atmosphere and clouds, light binning, skinning writeback, shadow meshlet cull, deferred decals.
Game-authored packages (sample project: src/games/example/assets/shaders/) stage under data/shaders/, not _engine/shaders/. Materials may set "binding_layout": "mesh_vertex_texture" and optional "async_compute": { "package", "entry", "resolution", "threads", "format", "lag_frames" } to drive content compute (producer key = package/entry/resolution/threads/format; lag_frames 0|1 only; prefer independent compute queue, fall back to graphics; format omit/r32f, rg16f, or rgba16f). Encode writes the selected ring textures onto GpuMaterialRecord.vertexTextureIndex / previousVertexTextureIndex in the bindless heap, so deforming vertex shaders can emit deformation-correct motion vectors. Producer kernels may sample their own prior ring result through content_compute.history; gate it with frame.historyValid and use simulation-clock frame.deltaTime for temporal evolution. Producers may also consume up to 64 game-published opaque records through content_compute.inputs / frame.inputCount; update them in one batch with hi.render().setContentComputeInputs. The engine tracks opaque vs transparent consumers so async-compute acquisition occurs at the first real sampling pass. Sample: packages water + water_sim.
Temporal geometry contract: opaque GBufferOutput.depth is float2(currentViewDepth, previousViewDepth); compute the previous depth from the same previous pose used for motion and write a nonpositive value when there is no correspondence. Forward/OIT entry points (including fallbacks) declare a third output float4(previousViewDepth, valid, 0, coverage); hikari_oit_encode weights it like colour. Particles without previous poses emit validity zero.
Composability: geometry packages (including content like water) write the deferred G-buffer only. They must not hardcode RT, deferred vs RT lighting, or other materials. Lighting / AO / reflection packages read the G-buffer (and optional TLAS) only. Content compute packages write a storage texture consumed through those bindless material heightfield indices. Full contracts: Rendering — Rendering contracts.
Shared forward BRDF/IBL helpers accept an optional trailing dielectric_f0
(float3(0.04) by default) and specular_occlusion (-1 = use the material
AO). Engine forward_lit / forward_lit_rt derive F0 from IOR
(hikari_dielectric_f0) and pass that same value to direct lighting, IBL, and
the transmission mix. A transmitting interface is a dielectric: metalness is
scaled by (1 - transmission) so it cannot steal F0, and IBL specular is not
multiplied by baked AO (the Fresnel lobe is the environment, not a cavity).
thickness == 0 is thin-walled: the raster path still walks the refracted ray
by one pixel of world length at the surface depth (Snell in screen space, no
Beer-Lambert). Volumes keep using authored thickness.
Formats, blend, and depth write live on PipelineKind, not shader #ifdef.
Optional map presence uses a runtime map mask in the material row (HikariGpuMaterial.material.w, mirror of GpuMaterialRecord)
(dynamic branches), not compile-time permutations.
Akari variant axes are only for finite, named build choices (e.g. tonemap
operator). Never emit a Cartesian product of material features. Lighting-family
scene textures (G-buffer, shadow atlas, skybox, captures, …) are bindless:
index them through @bind(lighting.resource_indices) (HikariLightingResources),
not fixed HK_TEXTURE(lighting,…) slots. The language still allows resource
arrays (TexCube[N] / Tex2D[N]) — see the
language reference.
PSO share
Mesh PSOs are keyed by (package, vertex entry, fragment entry, PipelineKind)
and shared across materials. Residency still refcounts handles.
Native shader programs are independently cached by package, entry points, binding layout, shader model, and AssetStore content version. Material instances retain the immutable program while keeping per-material compute/culling state. The cache drops its strong reference on shader-source rebind or hot reload; old programs survive only while deferred GPU materials still reference them. Unused programs age out after 120 frames, so streamed chunks do not grow an immortal shader cache.
Akari file headers
One root per file, then optional headers, then items (struct / fn / …):
#shader composite
#layout fullscreen_texture_table
#import preamble, fullscreen_triangle
@vertex
fn composite_vertex(@vertex_id vid: uint) -> V { … }| Directive | Role |
|---|---|
#shader name / #module name | File kind + name |
#version N | Syntax version (default 2) |
#layout name | Binding layout contract (#shader only) |
#import a, b | Pure modules from akariImportPaths |
#require a, b | Package capability declarations (optional; also inferred from use) |
Stages are attributes on functions (@vertex / @fragment /
@compute(threads=(x,y,z))), not text pragmas. D3D12 emit splits one entry per
file because DXC produces one stage artifact per invocation.
Current layouts: standard, fullscreen_texture_table, fullscreen_shadowed,
fullscreen_hybrid_shadowed, fullscreen_reflections, forward_lit, forward_lit_rt, fullscreen_raytraced,
debug_visualizers, ui / ui_atlas, mesh_vertex_texture.
Language surface (authoring)
Full syntax, tables, and worked examples: Akari language. Grammar: src/akari/syntax.ebnf.
GPU packing: src/akari/docs/abi.md.
Array textures sample with a packed coord (e.g. Tex2DArray uses float3(uv, layer));
Metal emit splits layer from the coord. Prefer @bind over raw @slot except
non-manifest compute experiments.
Binding manifest
Authoring uses @bind(group.name) / @target(group.name) / @sampler(…).
@bind and @target resolve slots from bindings.kaji.json. Manifest keys keep
the historical HK_* spelling:
HK_BUFFER(domain,name)
HK_TEXTURE(domain,name)
HK_TARGET(domain,name)@sampler(filter, address) (and the comparison form) is not a manifest key.
Metal emits a constexpr sampler; HLSL assigns s0, s1, … in declaration
order. D3D12 static samplers come from the Zig root-signature / compute
contract, not this JSON.
Unknown @bind / @target names are errors. Values come from the shared
bindingManifest; they are not hardcoded in .akari source.
Domains
Slot numbers live only in bindings.kaji.json; author against the names. The
domains you will meet:
| Domain | Names | Who reads it |
|---|---|---|
frame / draw / geometry | uniforms, object, vertices | geometry packages (mesh geometry is meshlet-pulled from meshlet.vis_tris / meshlet.works plus the bindless geometry table; shadow depth uses the same pull) |
resources | texture_table, geometry_samplers | material maps via GpuMaterialRecord indices; content-compute heightfields land on the same heap (vertexTextureIndex / previousVertexTextureIndex) |
lighting | frame, records, clusters, shadows, probes, resource_indices | deferred / forward lighting, AO, GI, reflections. Scene textures are bindless through resource_indices (HikariLightingResources) |
post | hdr, lut, bloom_add, exposure | tonemap / composite family |
ui | vertices, atlas | UI packages |
debug | albedo … hdr (eight float slots the active visualizer assigns) plus material (Tex2D<uint>, its own slot) | debug visualizers |
Samplers
@sampler is a shader-local declaration, not a bindings.kaji.json row. Metal
bakes a constexpr sampler; HLSL numbers sN in source order.
Most fullscreen and UI passes declare one @sampler(linear, clamp) (HLSL s0).
Deferred and hybrid lighting declare three, in this order:
@sampler(point, clamp)→ HLSLs0@sampler(linear, clamp)(sky / capture) → HLSLs1@sampler(comparison, greater_equal, linear, clamp)(reverse-Z shadow atlas) → HLSLs2
Render targets
G-buffer outputs use:
HK_TARGET(gbuffer,albedo)HK_TARGET(gbuffer,normal)HK_TARGET(gbuffer,arm)HK_TARGET(gbuffer,depth)HK_TARGET(gbuffer,material)(uint, notfloat)HK_TARGET(gbuffer,motion)HK_TARGET(gbuffer,anisotropy)HK_TARGET(gbuffer,clearcoat)
They expand to Metal color indices 0 through 7 and HLSL semantics
SV_TARGET0 through SV_TARGET7.
float4 albedo [[color(HK_TARGET(gbuffer,albedo))]];float4 albedo : HK_TARGET(gbuffer,albedo);Ordinary single-target HLSL fragment outputs continue to use native
SV_TARGET.
Authored foliage normals
material.flags.preserve_authored_normals opts foliage out of the default two-sided policy (which mirrors the normal on back faces): G-buffer, forward, material-graph, and RT secondary hits keep the authored side of the tangent frame for both faces. Normal maps and clearcoat still perturb it. It is a normal policy only: alpha coverage, shadows, translucency, and the BRDF are unchanged, and double_sided is still set separately. Model sidecars set preserve_authored_normals: true in an exact-name model.material_overrides entry so recooks keep the choice; no name heuristic exists. No shader permutations or GPU record bytes are added (one flag bit in the material word and RT instance descriptor).
Shader specifications
The JSON specification version must be 2:
{
"version": 2,
"type": "AppleMetal",
"bindingManifest": "bindings.kaji.json",
"akariImportPaths": ["akari/modules/"],
"compilation": [
{
"name": "gbuffer",
"workingDirectory": "akari/packages/",
"optimizationLevel": 0,
"includeDebugSymbols": true,
"sources": ["gbuffer.akari"],
"variants": {}
}
]
}Per-entry optimizationLevel / includeDebugSymbols are author defaults.
Product builds override them with Kaji --shaders:config=debug|release
(default debug), which is independent of product --config. That maps to:
--shaders:config | optimization | debug symbols |
|---|---|---|
debug | 0 | on |
release | 2 | off |
akari compile accepts --config=debug|release. Shinra forwards the same
profile via --shaders-config= (hyphen; Clap cannot use : in long option
names) and/or env.
variants is optional and defaults to an empty object. Dual tonemap artifacts
share one source with different selections:
{ "name": "tonemap", "sources": ["tonemap.akari"], "variants": { "TONEMAP": "aces" } }
{ "name": "tonemap_reinhard", "sources": ["tonemap.akari"], "variants": { "TONEMAP": "reinhard" } }bindingManifest and akariImportPaths may be omitted when --engine-sdk can
resolve bindings.kaji.json and engine akari/modules/ from the SDK tree.
The engine and game specifications both reference
src/hikari/shaders/bindings.kaji.json. Each manifest entry maps one complete
semantic expression to its Metal and D3D12 token, and optionally a Vulkan one:
{
"version": 1,
"bindings": {
"HK_BUFFER(frame,uniforms)": {
"metal": "1",
"d3d12": "b0",
"spirv": "0:1"
}
}
}Keep one shared manifest for all packages so game-authored and built-in shaders cannot drift.
Vulkan binding model
Vulkan has no per-class register namespaces, so Akari fixes one descriptor-set
layout (akari_emit_common::vulkan) that every Vulkan pipeline layout mirrors:
| Set | Contents | Binding |
|---|---|---|
| 0 | Uniform / Buf / RwBuf / AtomicBuf / AccelStruct | the Metal buffer index |
| 1 | sampled and storage textures | the Metal texture index |
| 2 | samplers; @sampler(...) descriptors take the next free binding in declaration order | the Metal sampler index |
| 3 | renderer-global: 0 bindless ByteAddressBuffer[], 1 Texture2D[], 2 TextureCube[], 3 Texture3D[], 4 Texture2D<float>[] (depth), 5 geometry sampler catalog SamplerState[18] | fixed |
An entry without a spirv value therefore needs no edit: its Metal index is the
binding inside the class's set. "spirv": "set:binding" overrides that for one
entry. A D3D12 register in the spirv column is rejected. Render targets use the
Metal attachment index (SV_TargetN → Location N). @slot(N) keeps N in the
class set. Storage textures carry [[vk::image_format]] for every format except
float4, which stays Unknown (format-less writes). Compilation passes
-fvk-use-dx-layout (D3D12 packing), -fvk-use-dx-position-w, and
-fvk-invert-y on vertex stages, so CPU-side layouts and clip conventions are
the ones D3D12 and Metal already use. Output is one <package>_<vs|ps|cs>.spv
per stage entry.
Processing order
For each package, Akari:
- Parses the
.akaripackage and transitive#importmodules. - Lowers AST → HIR → IR (variants selected from the spec entry).
- Emits pure MSL, HLSL, or Vulkan HLSL (bindings resolved;
#line+ stage metadata). - Validates emit purity (no leftover
HK_*/#pragma hikari). - Invokes Metal, DXC, or DXC
-spirv. - On failure, prints
@akari diag {"sev","msg"}to stderr (machine), then humanerror:lines — metalc/DXC locations are remapped via.linemap.json/#lineto.akariwhen possible; Metal unknown-attribute / candidate-note noise is dropped.
Package failures are isolated. Akari completes every independent package in the spec and exits non-zero only after reporting the failed set. Successful packages publish atomically; a failed package does not replace its previous library. A shared manifest or imported-module error still fails every package that actually depends on that shared input.
Emitted sources for debug live under
<shader-cache>/akari_emit/<package>/.
Validation
Akari can validate metadata, imports, bindings, stages, layouts, and variants without invoking DXC:
bin/akari/akari compile \
--spec=src/hikari/shaders/build.akari.d3d12.json \
--out=build/cache/shader-validation/out \
--cache=build/cache/shader-validation/cache \
--engine-sdk=. \
--validate-onlyvalidate-only does not type-check native HLSL. The Windows build remains the
authoritative DXC validation. The Vulkan spec has no such gap: without
--validate-only it runs the vendored DXC in SPIR-V mode on every host, and DXC
validates each module it writes, so the same command against
build.akari.vulkan.json is a full compile check.
Validate the game's shaders against the engine's, not only the engine's. A
game package that calls into an engine module is compiled against whatever that
module currently declares, so an engine-side change can break a game shader that
nobody touched — and the failure surfaces at cook time in the editor console,
not in any Zig build. Point the same command at the game's spec with
--engine-sdk naming the engine shader root:
bin/akari/akari compile \
--spec=src/games/example/assets/shaders/build.akari.metal.json \
--engine-sdk=src/hikari/shaders \
--out=build/cache/shader-validation/out \
--cache=build/cache/shader-validation/cache \
--validate-onlyAlways pass --out and --cache pointing somewhere disposable. Without
--validate-only this recompiles every package and overwrites the staged
metallibs for the given config.
HikariRasterLightCtx is a struct literal game shaders build, so a field
added to it is a breaking change. Anything calling evaluate_lighting_forward
and friends constructs one by naming every field, and Akari rejects a literal
that misses one (missing field 'x' in 'HikariRasterLightCtx' literal) — there
is no default, because the pack holds textures and samplers and neither can be
zero-initialized. When the engine adds a resource to the light loops, every game
shader that lights a surface needs the new field; mirror what
akari/packages/forward_lit.akari does for it (the sample's water.akari is the
worked example).
Format
akari fmt reprints .akari source in the canonical style (4-space indent,
semicolons, name: Type spacing, wrapped parameter/argument lists). It parses
and pretty-prints; it does not type-check or compile. Comments are preserved.
bin/akari/akari fmt src/hikari/shaders/akari
bin/akari/akari fmt --check src/hikari/shaders/akari
bin/akari/akari fmt --stdin < foo.akariDirectories are walked recursively for *.akari. --check lists files that
would change and exits 1. Default writes in place.
Authoring rules
- Author packages and modules as pure
.akarionly (#shader/#module,#layout,#import). - Bind with
@bind(group.name)/@target(group.name)— not raw registers.@sampleris shader-local (Metal constexpr / HLSLsNin declaration order) and does not take a manifest row. - Keep CPU/GPU structure layouts synchronized with their Zig definitions
(
akari transpile --dump-layout/ ABI rules insrc/akari/docs/abi.md). - Add a binding to the shared manifest and this document in the same change.
- Treat missing imports and unknown bindings as errors; never substitute empty source.
- Prefer runtime map masks over new variant axes for material features; keep variants finite and named (tonemap operator, etc.).