GPU Compute#
OneJS provides a bridge to Unity's compute shader API, enabling GPU-accelerated computations from JavaScript. The onejs-unity package offers a fluent TypeScript API for working with compute shaders.
Related: Zero-Allocation Interop (for per-frame dispatch optimization)
Platform Support#
| Platform | Compute Shaders | Async Readback |
|---|---|---|
| Windows/macOS/Linux | Yes | Yes |
| iOS/Android | Yes (most devices) | Yes |
| WebGL | No | No |
| WebGPU | Yes | Yes |
Setup#
1. Register Shaders in C##
Compute shaders must be registered before JavaScript can access them:
using OneJS.GPU;
public class MyShaderProvider : MonoBehaviour {
public ComputeShader particleShader;
void OnEnable() {
GPUBridge.Register("ParticleUpdate", particleShader);
}
void OnDisable() {
GPUBridge.Unregister("ParticleUpdate");
}
}2. Install onejs-unity#
npm install onejs-unityBasic Usage#
import { compute, Platform } from "onejs-unity"
// Check platform support
if (!Platform.supportsCompute) {
throw new Error("Compute shaders not supported")
}
// Load a registered shader
const shader = await compute.load("ParticleUpdate")
// Create a buffer with initial data
const positions = compute.buffer({
data: new Float32Array([0, 0, 0, 1, 1, 1])
})
// Dispatch a kernel with the fluent API
shader.kernel("CSMain")
.float("deltaTime", 0.016)
.float("speed", 5.0)
.buffer("positions", positions)
.dispatch(1)
// Read results back from GPU
const result = await positions.read()
console.log(result) // Float32Array with updated values
// Clean up
positions.dispose()
shader.dispose()Fluent API#
The kernel builder provides a chainable API for setting uniforms and dispatching:
Scalar Uniforms#
shader.kernel("CSMain")
.float("time", performance.now() / 1000)
.int("count", 1024)
.bool("enabled", true)Vector Uniforms#
shader.kernel("CSMain")
.vec2("resolution", [1920, 1080])
.vec3("gravity", [0, -9.8, 0])
.vec4("color", [1, 0.5, 0.2, 1])Vectors accept arrays or objects with x, y, z, w properties.
Matrix Uniforms#
const transform = new Float32Array(16)
// ... fill matrix data
shader.kernel("CSMain")
.matrix("worldMatrix", transform)Buffer Bindings#
// Create buffers
const input = compute.buffer({ data: new Float32Array(1024) })
const output = compute.buffer({ count: 1024 })
// Bind to kernel
shader.kernel("CSMain")
.buffer("inputData", input)
.buffer("outputData", output)
.dispatch(16) // 16 thread groupsDispatch#
// 1D dispatch
shader.kernel("CSMain").dispatch(64)
// 2D dispatch
shader.kernel("CSMain").dispatch(32, 32)
// 3D dispatch
shader.kernel("CSMain").dispatch(8, 8, 8)
// Multiple iterations: repeat() dispatches one thread group each time,
// so loop over dispatch() when the kernel needs more
shader.kernel("Simulate").repeat(100)Buffers#
Creating Buffers#
// From existing data
const buffer = compute.buffer({
data: new Float32Array([1, 2, 3, 4])
})
// Empty buffer with count
const empty = compute.buffer({ count: 1024 })
// Typed buffers
const ints = compute.buffer({
data: new Int32Array([1, 2, 3])
})The shader declares the type the buffer was made from: float for a Float32Array, int for an Int32Array and uint for a Uint32Array, as in RWStructuredBuffer<int> counts;. Reading the buffer back gives the same array type.
Writing Data#
buffer.write(new Float32Array([5, 6, 7, 8]))Reading Data (Async)#
GPU readback is asynchronous:
const result = await buffer.read()For non-blocking checks:
// Start readback
buffer.read().then(data => {
console.log("Readback complete:", data)
})
// Check if ready
if (buffer.readbackReady) {
const data = buffer.readbackResult
}Structured Buffers#
Define struct types matching your HLSL:
compute.struct rounds the stride up to a multiple of 16 bytes, so pad the HLSL struct to match (28 bytes of fields here, padded to 32):
// HLSL struct:
// struct Particle {
// float3 position;
// float3 velocity;
// float life;
// float pad;
// };
const ParticleType = compute.struct({
position: "float3",
velocity: "float3",
life: "float"
})
const particles = compute.buffer({
count: 1000,
type: ParticleType
})Compute Shader Example#
HLSL compute shader (ParticleUpdate.compute):
#pragma kernel CSMain
RWStructuredBuffer<float> positions; // x, y, z per particle
float deltaTime;
float speed;
uint count;
[numthreads(64, 1, 1)]
void CSMain(uint id : SV_DispatchThreadID) {
if (id >= count) return;
positions[id * 3 + 1] += speed * deltaTime;
}A buffer made from a Float32Array has one 4-byte element per number, so the shader declares float and indexes three per particle. JavaScript usage:
const shader = await compute.load("ParticleUpdate")
const positions = compute.buffer({
data: new Float32Array([0,0,0, 1,0,0, 2,0,0]) // 3 particles
})
// Update loop
function update(dt: number) {
shader.kernel("CSMain")
.float("deltaTime", dt)
.float("speed", 2.0)
.int("count", 3)
.buffer("positions", positions)
.dispatch(1)
}Render Textures#
A kernel can write straight into a RenderTexture that an element uses as its background. Pass the shader object in through the JSRunner's globals and register it with compute.register, or use the hooks, which handle creation, resizing and cleanup:
import { View } from "onejs-react"
import { useComputeShader, useComputeTexture, useAnimationFrame } from "onejs-unity/gpu"
function Background({ shaderGlobal }) {
const shader = useComputeShader(shaderGlobal)
const texture = useComputeTexture({ autoResize: true })
useAnimationFrame(() => {
if (!shader || !texture) return
shader.kernel("CSMain")
.float("_Time", performance.now() / 1000)
.textureRW("_Result", texture)
.dispatchAuto(texture) // ceil(width / 8) x ceil(height / 8) groups
})
return <View style={{ width: "100%", height: "100%", backgroundImage: texture }} />
}Without React, compute.renderTexture({ width, height }) or compute.renderTexture({ autoResize: true }) creates one; call dispose() when you are done with it.
Performance Tips#
- Minimize readbacks: GPU-to-CPU transfers are slow
- Batch dispatches when possible
- Use appropriate thread group sizes (typically 64, 128, or 256)
- Keep buffers GPU-resident when data doesn't need CPU access
- Profile with Unity's Frame Debugger
- Use zero-allocation interop for per-frame dispatch calls
API Reference#
Platform#
Platform.supportsCompute // boolean
Platform.supportsAsyncReadback // boolean
Platform.maxComputeWorkGroupSize // [x, y, z]compute#
compute.load(name: string): Promise<ComputeShader>
compute.register(shaderObject, name?): ComputeShader
compute.renderTexture(options): RenderTexture
compute.buffer<T>(options: BufferOptions<T>): ComputeBuffer<T>
compute.struct(schema: StructSchema): StructTypeComputeShader#
shader.kernel(name: string): KernelBuilder
shader.createDispatcher(kernelName, schema?): KernelDispatcher
shader.readback<T>(bufferName: string): Promise<T>
shader.dispose(): voidKernelBuilder#
.float(name, value) // Set float uniform
.int(name, value) // Set int uniform
.bool(name, value) // Set bool uniform
.vec2(name, value) // Set Vector2
.vec3(name, value) // Set Vector3
.vec4(name, value) // Set Vector4
.matrix(name, value) // Set Matrix4x4
.buffer(name, data) // Bind buffer
.bufferReadOnly(name, data) // Bind a read-only buffer
.texture(name, rt) // Bind a RenderTexture for reading
.textureRW(name, rt) // Bind a RenderTexture for writing
.dispatch(x, y?, z?) // Execute kernel
.dispatchAuto(rt, threadGroupSize?) // Groups to cover the texture (default 8)
.repeat(iterations) // Dispatch one thread group, iterations timesComputeBuffer#
buffer.count // Number of elements
buffer.stride // Bytes per element
buffer.write(data) // Upload data
buffer.read() // Async readback
buffer.readbackReady // Check if ready
buffer.readbackResult // Get cached result
buffer.dispose() // Release resources