GPU programming

GPU programming

TinyFX lets CPU orchestration, shaders, and data-parallel kernels share one program and one type checker. The compiler rejects a path that crosses the CPU/GPU boundary illegally and reports the call chain that made it reachable.

This page describes authored semantics. The compiler and runtime implementation of those semantics is in compiler and renderer.

Stages and callable functions

TinyFX has three source-level function forms:

fn ordinary(x: float) -> float { return x * 2.0; }
cpu fn loadOrchestrate() { /* CPU-only work */ }
gpu fn lighting(n: float3, l: float3) -> float {
  return max(dot(n, l), 0.0);
}
  • gpu declares a function intended to be valid in both CPU and GPU reachability contexts. A CPU call is lowered for the VM or generated CPU target; a shader or kernel call is lowered to WGSL.
  • cpu is explicitly CPU-only.
  • Plain fn is analysed from its uses. It can stay CPU-only, become GPU-compatible, or produce a stage diagnostic when a GPU-reachable use reaches a CPU-only operation.

GPU contexts include vertex/fragment shader bodies and parallel for kernel bodies. Strings, maps, dynamic arrays, trait objects, UI, files, asset loading, profiling, and asynchronous host operations are CPU-only. A GPU-compatible struct or capture cannot contain a CPU-only value.

Shaders

Shaders are first-class values. Use an inline literal for a draw or store it in a variable/array; named shader declarations are also supported.

let scene = shader {
  vert {
    out.position = env.camera.getCombinedMatrix() * (in.position, 1.0);
  }
  frag {
    out.color = (in.normal * 0.5 + 0.5, 1.0);
  }
};
 
draw(mesh, scene);

A fragment-only shader is a fullscreen shader: TinyFX supplies the passthrough vertex work. A mesh shader normally writes out.position from vert.

Cube

A rotating cube that introduces meshes, shaders, and orbit controls.
Preview

Inputs and outputs

The common vertex inputs are in.position, in.normal, in.uv, in.color, in.vertexIndex, and in.instanceIndex. Fragment code receives interpolated in.uv, in.position, in.normal, in.color, and in.screen.

Fragment in.depth is a special, read-only float: normalized raster depth after projection and viewport conversion. It is not the z component of in.position. It is useful when only some fragment paths override depth:

frag {
  if (needsCustomDepth) {
    out.depth = matrix::projectDepth(viewProj, worldPosition);
  } else {
    out.depth = in.depth;
  }
  out.color = shadeSurface();
}

Fragment outputs are out.color, out.attachment0, additional MRT attachment slots, and optional out.depth. If a shader writes an output on any live path, it must definitely write it on every path that reaches fragment output or an early return; a discard() path is exempt. The compiler also applies the same idea to vertex-to-fragment varying values unless their declaration has a default. Finite fragment depth is clamped to [0, 1]; non-finite depth discards that fragment.

Declarations, defaults, and attributes

Shaders may declare varyings, custom float-valued attributes, and declaration defaults:

shader Lit {
  varying lightAmount: float = 0.0;
  attribute tangent: float4;
  uniform tint: float4 = (1.0, 1.0, 1.0, 1.0);
 
  vert {
    lightAmount = 1.0;
    out.position = (in.position, 1.0);
  }
  frag { out.color = tint * lightAmount; }
}

A varying default supplies the value if vertex code does not assign it. A uniform declaration default is evaluated when a shader value is constructed, including named shader instantiation; it is not an external mutable-uniform setter. Custom mesh attributes are currently float through float4 only. mesh.setAttribute uses float storage; integer custom attributes are not a portable supported surface.

Captures and higher-order GPU code

Shader literals close over compatible values. Locals are snapshotted when the shader value is constructed; globals are read live when the draw is encoded. The checker narrows structured global reads, so a shader that uses one field of env does not upload the whole environment. Captured buffers/textures retain their checked resource identity.

GPU code can accept function-typed parameters when the argument at the GPU call site is a literal arrow. The compiler specializes that call and threads captures into the WGSL function:

let d = sdf::march(ro, rd, (p: float3) => sceneDistance(p, time));

This is deliberately narrower than CPU closures. GPU function values cannot be forwarded through another function or stored for later GPU use, and arbitrary arrow expressions inside emitted GPU bodies are rejected.

Derivatives and sampling

dpdx, dpdy, fwidth, and texture2d.sampleGrad are fragment-only. WGSL requires derivatives in uniform control flow, so TinyFX rejects a derivative or gradient sample in a branch/loop whose condition is fragment-varying. Compute the value before that control flow and branch on the result instead.

texture.sample() uses level zero. sampleLevel selects an explicit LOD; sampleGrad uses explicit gradients. A stale mip chain is an error for a higher-level sample rather than silently sampling the wrong resource state.

Buffers and texture ownership

Buffers

let heights = buffer<float>(64);
heights[0] = 1.0;
heights.upload();

Buffers begin CPU-owned. Indexed CPU writes change the VM/generated core copy; upload() publishes its current snapshot to GPU consumers. CPU reductions use the CPU-visible data.

A parallel for that writes a buffer makes it GPU-owned. CPU assignment and upload() then fail rather than racing a GPU writer. Reads, reductions, and download() use the latest completed snapshot; a native sync() is the explicit blocking escape hatch when the exact current-frame result is required. Browser paths deliberately expose non-blocking snapshots instead.

Textures

let color = texture2d<float4>((512, 512), TextureFormat.rgba16Float);
let depth = texture2d<float>((1024, 1024), TextureFormat.depth32Float);
let thickness = texture2d<float>((512, 512), TextureFormat.r16Float);
let scalarVolume = texture3d<float>((64, 64, 64), TextureFormat.r32Float);
let volume = texture3d<float4>((64, 64, 64), TextureFormat.rgba8Unorm);

The rgba* color formats are valid for texture2d<float4> and texture3d<float4>. TextureFormat.r16Float and TextureFormat.r32Float are single-channel color formats for texture2d<float> and texture3d<float>; a fragment shader still writes a float4, and the target stores its x component. Without an explicit format, float4 textures use rgba16Float and scalar textures use r32Float. TextureFormat.depth32Float remains specific to texture2d<float> depth textures.

Choose the scalar format by operation as well as precision. r16Float is a portable filterable render target and supports blending, including additive thickness accumulation. It is not a portable storage-write format, so a texture-domain parallel for cannot write it; texture.step(shader) can use the fragment fallback instead. r32Float preserves full scalar precision and supports portable storage writes, but portable filtering and blending are not guaranteed. TinyFX therefore binds r32Float with a non-filtering nearest sampler; indexed reads are the predictable choice for data textures.

Textures support indexed CPU access, sampling, size, clearing, optional mip chains, and non-blocking snapshots. Two-dimensional scalar textures support generateMips(). Three-dimensional scalar mip generation is not portable and is currently rejected. GPU-created results normally become CPU-visible on a later frame; native sync() can wait when necessary.

generateMips() expands a color texture's chain and refreshes it from level zero. Any level-zero draw, step, kernel, clear, or CPU write invalidates higher levels until it is called again. Depth mipmaps are unsupported.

Feedback

texture.step(shader) is the shorthand for a single-target feedback pass. It double-buffers the texture: reads see the front image, the pass writes the back image, and the images flip after the pass. A drawAdvanced feedback pass does the same coherently for all of its attachments.

A normal draw cannot read a texture it is simultaneously targeting, including through a property such as .size; dynamic aliasing is diagnosed at runtime. Use feedback when previous-frame data is the intended semantics.

Drawing and depth

The ordinary forms are:

draw(shader { frag { out.color = (in.uv, 0.0, 1.0); } });
draw(mesh, scene);

Use drawAdvanced for named configuration:

drawAdvanced(
  mesh: mesh,
  shader: scene,
  attachments: [albedo, normals],
  depth: depth,
  instances: 100,
  blend: Blend.alpha,
  depthConvention: DepthConvention.reverseZ,
  depthOnly: false,
  label: "scene.opaque",
);

shader: is required; other fields are optional. The typed option enums are nominal TinyFX values, not strings. Blend.none, Blend.alpha, and Blend.add control blending. Main-target draws default to alpha blending; offscreen targets and texture draws default to replacement (Blend.none) so data textures retain exact writes.

DepthConvention.standard pairs standard finite projection with an automatic clear of 1.0 and DepthCompare.lessEqual. DepthConvention.reverseZ pairs infinite-far reverse-Z projection with an automatic clear of 0.0 and DepthCompare.greaterEqual. Camera methods accept the convention too:

let viewProj = env.camera.getCombinedMatrix(DepthConvention.reverseZ);
drawAdvanced(depthConvention: DepthConvention.reverseZ, shader: scene);

Use DepthCompare.disabled when a draw needs neither depth testing nor depth writes. With no explicit depth: attachment, it also prevents allocation of an implicit main or offscreen companion depth buffer. It cannot be combined with depthOnly: true, an explicit depth attachment, or a fragment shader that writes out.depth.

Explicit depth textures remain explicitly cleared. depthOnly: true makes a depth pass; fragment out.depth cannot be combined with feedback or a depth-only pass. A depth-aware offscreen draw whose attachments omit depth lazily gets a same-size companion depth buffer, not a screen-sized one. Fullscreen color-only passes remain depthless.

Data-parallel work

parallel for is TinyFX's race-free compute form:

parallel for i in 0..positions.len() {
  positions[i] = positions[i] + velocities[i] * env.deltaTime;
}
 
parallel for pixel in color {
  color[pixel] = (float(pixel.x) / float(color.size.x), 0.0, 1.0, 1.0);
}

The compiler knows the range, 2D texture, or 3D texture domain and emits WGSL and physical compute information. It owns workgroup choices: source code never observes a workgroup size. The runtime filters a bounded set of safe candidates against adapter limits and may select/refine one without changing program ordering or re-running work merely to benchmark it. --explain-passes shows static candidates and rejection reasons; --trace-passes shows observed native selection.

Algorithms that need retained in-place storage, integer atomics, dynamic output, deterministic scan/group operations, or GPU-driven counts use the separate structured advanced-compute model. Ordinary parallel for keeps its existing snapshot publication semantics.

Kernel semantics remain deterministic and race-free. Do not rely on a workgroup shape, barriers, shared memory, scatter writes, or atomics as a user-visible feature.

Profiling

Use literal profile scopes around CPU orchestration and the GPU commands it encodes:

profile "fluid.frame" {
  profile "fluid.solve" {
    updateSimulation();
  }
  drawAdvanced(shader: present, label: "fluid.present");
}
 
let latest = profile::latest("fluid.frame/fluid.solve");
stat("solve CPU ms", latest.cpuMs);

Scopes are CPU-only and nest as / paths. cpuMs measures inclusive VM/host time and command encoding, not GPU execution. Physical GPU passes encoded in a scope are attributed asynchronously to its gpuMs, renderMs, and computeMs; those values can trail CPU data or remain -1 if timestamps are unavailable. drawAdvanced(label:) creates a leaf name for one render pass; it does not change pipeline identity.

On native, tfx run app.tfx --profile exports all observed scope paths without requiring matching stat() calls in the program. The report also includes wall/CPU frame timings and transfer counters for texture clears, buffer uploads, GPU-native buffer clears, and snapshot maintenance that are intentionally outside GPU pass timestamps.

env.stats distinguishes logical requests from physical work. See runtime metrics for the counters and renderer for accounting boundaries.