Write a WebGL 2 command, use it like a component.
Sahti lets you combine the power of instanced WebGL 2 rendering with the familiar API of front-end component frameworks.
Sahti is still unstable and experimental.
npm install --save @vuoro/sahti
yarn add @vuoro/sahti
https://www.npmjs.com/package/@vuoro/sahti
- React
import {component, Canvas} from "@vuoro/sahti/react";
- Preact
import {component, Canvas} from "@vuoro/sahti/preact";
- Custom
import {component, createRenderer} from "@vuoro/sahti";
- Web Components (soon, hopefully)
Demo and source: https://vuoro.github.io/sahti/examples/minimal.react.html
import { Canvas, component } from "@vuoro/sahti/react";
const triangle = [
[-1, -1, 0],
[1, -1, 0],
[-1, 1, 0],
];
const RedTriangle = component({
context: { triangle },
props: { position: [0, 0, 0] },
vertex: `
void main() {
gl_Position = vec4((triangle + position) * 0.25, 1.0);
}
`,
fragment: `
out vec4 pixelColor;
void main() {
pixelColor = vec4(1.0, 0.0, 0.0, 1.0);
}
`,
});
const App = () => (
<>
<RedTriangle />
<RedTriangle position={[2, 0, 0]} />
<RedTriangle position={[-2, 0, 0]} />
<Canvas style={{ width: "100%", height: "100vh" }} />
</>
);
Demo and source: https://vuoro.github.io/sahti/examples/maximal.react.html
Demo and source: https://vuoro.github.io/sahti/examples/minimal.preact.html
Creates a single, instanced, decently well optimized (I hope) WebGL 2 draw call.
const MyComponent = component({
// See below for what you can put in these.
context = {},
props = {},
// Your WebGL 2 shader strings. Required. See below for details.
vertex,
fragment,
// The `mode` parameter in `gl.drawArrays`
// https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/drawArrays#parameters
mode = "TRIANGLES",
// The depth function in `gl.depthFunc`
// (Can be set to a falsy value to disable `gl.DEPTH_TEST`.)
// https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/depthFunc
depth = "LESS",
// The `mode` parameter in `gl.cullFace`
// (Can be set to a falsy value to disable `gl.CULL_FACE`.)
// https://developer.mozilla.org/en-US/docs/Web/API/WebGLRenderingContext/cullFace
cull = "BACK",
// Override the automatically inserted shader precision lines
vertexPrecision = "precision highp float;",
fragmentPrecision = "precision highp float;",
order
})
Returns an object with 3 methods for managing the instances of the draw call:
import { component } from "@vuoro/sahti";
const { addInstance, deleteInstance, updateInstance } = component({…});
// Set up an object as the identity of your instance,
// and use it to add or remove it as needed.
const myInstance = {};
addInstance(myInstance);
deleteInstance(myInstance);
// Update the `props` of your instance like this
updateInstance(
myInstance,
"position",
[0, 1, 0]
);
context
can contain references to objects or arrays. Each will be interpreted as either a WebGL attribute (an array), a texture (an object with the "sampler" key set), or a uniform block (other objects). Sahti will set up the appropriate buffers and other WebGL-related things for each.
The same piece of context can be shared between multiple components. Only 1 buffer/texture/uniform block will be created.
const triangle = [[…], […], […]];
const world = { time: Date.now(), lightDirection: [0, -1, 0] };
const PlainTriangle = component({context: { triangle, world }, …})
const FancyTriangle = component({context: { triangle, world }, …})
You can also update the data in these context pieces at any time:
getContext(triangle).update(new Float32Array(9));
getContext(world).update("time", Date.now());
props
contains examples of the kind of data your components will be able to take in. Each of these will become an instanced attribute buffer, automatically updated as your components mount and update.
const Example = component({props: { position: [0, 0] }, …});
…
<Example/> // defaults to the above example value of [0, 0]
<Example position={[1, 0]}/>
<Example position={[1, 1]}/>
vertex
and fragment
are the shaders you'll write. Sahti will automatically insert all the attributes, uniform blocks, and texture uniforms based on the context
and/or props
you provide.
It will also add the shader version and precision lines. (Optionally customizable with vertexPrecision
, fragmentPrecision
.)
component({
context: { triangle: [[…], […], […]], world: { time: 0 } }
props: { position: [0, 0] },
vertex: `
// Inserted automatically:
// -----------------------
// #version 300 es
// precision highp float;
// in vec3 triangle;
// in vec2 position;
// uniform world { float time; };
void main() {
gl_Position = vec4((triangle + vec3(position, 0.0)) * 0.25, 1.0);
}
`,
fragment: `
// Inserted automatically:
// -----------------------
// #version 300 es
// precision highp float;
// uniform world { float time; };
out vec4 pixelColor;
void main() {
pixelColor = vec4(1.0 - time * 0.01, 1.0, 1.0, 1.0);
}
`,
})
Same API as above, but returns a React component that handles addInstance, deleteInstance, updateInstance
automatically.
The component optionally supports forwardRef
. ref.current
will be set to [instance = {}, update = (name, value) => updateInstance(instance, name, value)]
.
import { component } from "@vuoro/sahti/react";
const RedTriangle = component({…});
const App = () => {
const ref = useRef();
return (
<>
<RedTriangle ref={ref}/>
<RedTriangle position={[1, 0]}/>
<RedTriangle position={[1, 1]}/>
</>
);
}
Does the same as component
above, but in the form of a React hook.
import { component } from "@vuoro/sahti";
import { useComponent } from "@vuoro/sahti/react";
const RedTriangle = component({…});
const MyRedTriangle = (props) => {
const [instance, updateInstance] = useComponent(RedTriangle, props, enabled = true);
return null;
}
Because every WebGL library needs its own, opinionated camera implementation. :) It automatically updates a the context in your draw calls with projection
and view
matrices.
This module is completely optional to use, and will not be included in your JS if you don't use it.
import { component } from "@vuoro/sahti";
import createCamera from "@vuoro/sahti/camera";
const [camera, cameraObject] = createCamera({
// Field of view in degrees
// (uses orthographic camera if not set, perspective camera if set)
fov,
// Near and far planes
near = 0.1,
far = 1000,
// Only used for orthographic camera
zoom = 1,
// Optionally adds `vec3` `cameraPosition`, `cameraTarget`, and `cameraUp` vectors to your shaders
includePosition = true,
includeTarget = true,
includeUp = false,
});
component({context: {camera}, …});
// Updating any of these triggers an automatic update
cameraObject.fov = 60;
cameraObject.near = 0.01;
cameraObject.far = 500;
cameraObject.zoom = 1;
// Don't try to reassign these. Instead just modify their contents, or use the Float32Array API:
// https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Float32Array
// And finally call `update()`, because they won't know to update automatically.
cameraObject.position[0] = 1;
cameraObject.target[0] = 1;
cameraObject.up.set([0, -1, 0]);
cameraObject.update();
You can start creating components, mounting instances, and updating context resources at any time: no need to wait for a <canvas>
to initialize a WebGL context. Once a context becomes available, all context and instance updates will be "played back" in sequence. This also means Sahti can take a WebGL context loss and restoration without stopping the entire app.
All created draw calls will be called on the next requestAnimationFrame
, whenever:
- they're created
- a canvas context is available (or restored after a failure)
- its instances change or update
- one of its context resources gets updated
- another draw call is created
To use the same requestAnimationFrame
loop as Sahti, you can use useAnimationFrame
or requestJob
. If you update any context pieces or instances with these, Sahti will call all draw calls at the end of the same frame.
import { useAnimationFrame, requestJob } from "@vuoro/sahti";
// Called on every frame.
useAnimationFrame(() => {});
// Called on every nth (8th here) frame. Handy if you don't need to do something on _every_ frame.
useAnimationFrame(() => {}, 8);
// Called once, on the next frame.
requestJob(() => {});
Sahti uses a ResizeObserver
to respond to <canvas>
dimension changes. Due to how it's implemented, it's important to set your <canvas>
some kind of width and height with CSS, or you'll end up with a massive broken <canvas>
.
@vuoro/sahti/react
(and any other upcoming variants) will also export everything from @vuoro/sahti
. So both of these will work:
import { useAnimationFrame } from "@vuoro/sahti";
import { useAnimationFrame } from "@vuoro/sahti/react";
- https://twitter.com/jonikorpi/
- https://twitter.com/VirtanenS (library name)