Documentation · Concepts
A texture that is a program
A screen that animates is normally a stack of baked frames: seven kinds of console in three colours at eight frames each is 168 images, a hundred and sixty-eight megabytes, and it still steps — because eight frames is all there is. A program is one texture however long it runs, and it does not step. What it costs instead is arithmetic, on the CPU, on your thread, and that cost is two numbers you set.
The first one
A TextureProgram is a small program that computes an image. Hand it source, put it on a material, and the map that would have been a file is computed instead:
var screen = new TextureProgram("""
uniform float2 uSize;
uniform float uTime;
half4 main(float2 frag) {
float2 uv = frag / uSize;
float bar = step(fract(uv.x + uTime * 0.4), 0.5);
return half4(half3(0.3, 1.0, 0.5) * half(bar), 1.0);
}
""")
{
Size = new PixelSize(192, 192),
UpdatesPerSecond = 10
};
console.Material.EmissiveProgram = screen;
That is the whole of it. There is no toolchain, no build step and nothing to ship beside the assembly: the language is SkSL, Skia's own shading language, which is already a dependency of this library on every platform it runs on. It is GLSL-shaped, so the code above will look familiar to anyone who has written a fragment shader, and it is deliberately restricted to what every backend can run.
The entry point is half4 main(float2 frag), and frag is in pixels, not in
the nought-to-one coordinates you are probably expecting — divide by uSize for those.
What it returns is premultiplied, which is Skia's rule for every runtime shader and not a
choice made here. At full opacity — which is nearly every screen, sign and readout — there is no
difference to notice. Where there is one, a half-covered red is
half4(half3(1, 0, 0) * 0.5, 0.5), and writing the colour unscaled beside a half alpha asks for
a red twice as bright as red. The texture the program fills is straight, as
Texture.Pixels promises; the conversion happens on the way in.
The source is compiled once, when you construct it, and a program that will not compile never reaches a driver: Error holds the message, the texture stays transparent, and nothing is evaluated. Check it while you are writing one. The same field speaks if an evaluation ever fails while running: the last picture stays, the program stops, and nothing reaches your frame.
Where it fits, and what the renderers see
A program is a way of filling a texture, not a new kind of material. That is why it needed no change to any of the four renderers, and why there is nothing to check before using one.
Four maps can be computed, and each one keeps the meaning it already had:
BaseColorProgram |
The albedo map. sRGB-encoded — the renderers linearise it, so write the colour you want to see, not its square. |
EmissiveProgram |
The emissive map, sRGB-encoded and multiplied by EmissiveColor — which must be
non-black or the map is multiplied away. Screens, signs and readouts are nearly always this one. |
MetallicRoughnessProgram |
The glTF packing: green is roughness, blue is metallic, red and alpha unused. Linear data, not gamma-corrected. Rust, wear, wet patches, frost. |
NormalProgram |
A tangent-space normal map, linear, +Z out of the surface — so
half4(n * 0.5 + 0.5, 1). Needs tangents on the mesh. This is what makes water. |
Assigning a program points the corresponding texture at the program's own
Texture; clearing it takes the map away again, and changing the program's
Size moves the map to the new texture. From there everything downstream — the caches, the
uploads, the shaders — treats it as the ordinary texture it is. The texture can also be put on any other map
or on a SpriteNode directly, and the view still runs the program; only a
resize is not followed there, so assign it again after one.
What a program is given
Four things, and nothing else. Declare what you use; anything you do not declare costs nothing.
uniform float2 uSize; |
The image's size in pixels. Filled in for you. frag / uSize is the nought-to-one
coordinate. |
uniform float uTime; |
The scene's clock in seconds, filled in for you. An absolute reading rather than a step, so a
program's phase is a function of the time. A program with no uTime in it is
evaluated once and then only when something is set on it, because nothing about it can
have changed. |
Set(name, …) |
Your own constants, in the shapes listed below. This is how one piece of source becomes twenty different screens. |
SetTexture(name, texture) |
A map to sample, declared uniform shader name; and read with name.eval(p).
The coordinates handed to eval are pixels in this program's image,
so scrolling or tiling it is arithmetic on them. |
What is not there is the point of the design. A program computes an image, so it never sees the surface's normal, the direction you are looking from, the lights, or the scene behind the surface. Water that ripples its own colour is one of these; water that bends what is behind it is not. Nothing a program does reaches the lighting — it produces a map, and the material does what it has always done with that map.
The constants
One overload per shape SkSL can declare. Each is written into the program's uniform block at the call, and that write is the check.
Set(name, float) |
uniform float. Write 2f: a bare 2 is an int. |
Set(name, int) |
uniform int. |
Set(name, bool) |
uniform int, as one or zero — SkSL has no bool uniform. |
Set(name, Vector2) |
uniform float2. |
Set(name, Vector3) |
uniform float3. |
Set(name, Vector4) |
uniform float4. |
Set(name, Matrix3x2) |
uniform float3x3, laid out so that m * float3(p, 1) in the program is
Vector2.Transform(p, m) in C#. |
Set(name, Matrix4x4) |
uniform float4x4, so that m * float4(p, 1) is Vector4.Transform(p, m). |
Set(name, float[]) |
uniform float name[N]. Exactly N values; the array is copied. |
Set(name, Vector4[]) |
uniform float4 name[N]. Exactly N vectors. |
Set(name, SKColorF) |
uniform float4 — or a float3, which takes the colour without its alpha. |
Unset(name) |
Back to zero until it is set again. |
The shape is checked at the call. A Vector4 set on a uniform float
throws an ArgumentException that names both sides — uniform 'clock' is declared float;
Set(string, Vector4) was called — on your thread, at the line that is wrong, and never from inside a
frame. A name the program does not have is not an error: it is noted once in
Diagnostics and ignored, because one piece of source serves many
screens, and a constant a program has stopped reading is a drift to report rather than a frame to lose.
And set every constant you declare: a slot never written holds whatever memory it was given, not zero.
The two dials, which are the whole cost
Evaluation is per pixel, on the CPU, on the UI thread. There is nothing hidden to find: the cost is the number of pixels times the number of evaluations a second, and both are properties on the object. On a quiet machine one evaluation is about 20 ms at 512², and 1.3 ms at 128².
screen.Size = new PixelSize(128, 128); // detail — halve it and you quarter the work
screen.UpdatesPerSecond = 8; // rate — 30 by default, 0 means every frame
Size is detail, not size in the scene: how large the surface appears is the mesh's business. A
console across a room does not need more than 128, and it reads the same at eight updates a second as at
sixty. Twenty screens at 128² and 8 Hz is about two milliseconds of work a second; twenty at 512² and 30 Hz
is not affordable at all, and the difference between those two sentences is entirely in these two lines.
LastEvaluation reports what one evaluation cost and
Evaluations how many there have been. Multiply them and that is what the program has spent.
Two things are handled for you. A program is given its own phase of its update period, so twenty screens created together at thirty a second do not all come due on the same frame and cost twenty evaluations in one hitch. And a program whose source has no clock in it is evaluated once, however high the rate is set.
Randomness that survives the crossing
Three functions are available to every program without being declared:
float ava3dHash(float x); // 0..1 from a number
float ava3dHash2(float2 p); // 0..1 from a point
float ava3dNoise(float2 p); // smooth value noise
Use them rather than the hash out of every shader tutorial. fract(sin(x) * 43758.5453) is the
one thing measured across two backends here that did not survive the crossing: the same picture
came back with a mean channel difference of 0.057 against a bar of 0.002, because
multiplying by forty-three thousand and keeping the fraction turns one unit in the last place of
sin into a different random number. The permutation behind ava3dHash keeps every
intermediate an exact integer in a float; the same picture then agreed to 0.000028.
These are prepended to your source, and the line numbers in Error are corrected for them, so
the number in the message is the line in the code you wrote.
Changing one, and putting it away
Setting a constant or a texture marks the program dirty, and it is evaluated on the next tick whatever its rate says — a knob turned by a user does not wait a third of a second for its picture. One piece of source and one constant per surface is how a bank of screens is built:
foreach (var (console, phase) in consoles)
{
var program = new TextureProgram(Source) { Size = new PixelSize(128, 128) };
program.Set("uConst", new Vector4(phase, 0, 0, 0));
console.Material.EmissiveProgram = program;
}
Each surface needs its own instance, because a program owns one texture. What differs between them is a handful of numbers.
A program is disposable, and the reason is that a compiled effect is a native object. A game that builds a screen when a room is entered and drops it when the room is left would otherwise leave one behind each time, released only when a finaliser eventually ran. Disposing a program that is still on a material leaves the map as it was — the last picture it drew — and stops it changing.
One neighbouring fact, because it is the thing people meet next: a material is not watched.
Assigning or clearing a program is noticed, and so is anything a program does; changing something else on a
material that is already on screen is not, and
Material.Refresh() is how you say so. The demo's baked screen, which
steps its atlas by moving UvOffset, needs exactly that call.
Four things this turns out to be
- A screen, a terminal, a CRT, a readout. The case it was built for, on
EmissiveProgram. It replaces an atlas that grows with how smoothly the thing moves. - Water, and anything else that ripples. A
NormalProgramwhose normal is the gradient of a couple of moving noise fields. The rim term you already have is the fresnel, and the lighting does the rest. - A dissolve, a burn-through, a shield taking a hit. Coverage in the alpha of a
BaseColorProgram, withMaterial.AlphaCutoffdeciding where the hole is — and the one recipe here where the premultiplied return above actually bites. - Signage. Texture.FromText into
SetTexture, then scroll it by arithmetic on the coordinates handed toeval— one texture, any length of message.
What it is not is a way into the lighting. A fragment program that participates in shading — with the interpolated normal, the view vector and the tangent frame — is a different feature and is not in this release. If you find yourself wanting the scene behind the surface, that is the boundary you have reached.
Run it
Three programs against a baked eight-frame atlas of the same screen. Watch the right-hand one step.
AVA3D_SCENE="Programmable screens" dotnet run --project samples/Ava3D.Demo.Desktop
The same scene with the probe, which prints what each frame cost:
AVA3D_SCENE="Programmable screens" AVA3D_PROBE=8 dotnet run --project samples/Ava3D.Demo.Desktop