Documentation · 3D Guide
5 · Surfaces and light
A triangle on its own is a flat coloured shape. Everything that makes it look like brushed steel or wet clay or a painted hull happens in this chapter: what a shader is, what a normal is for, the two dials that do most of the work, and the maps that vary them across a surface.
What a shader is, and why you are not writing one
A shader is a small program the graphics card runs an enormous number of times in parallel. There are two of them in play for every triangle you draw:
- The vertex shader runs once per corner. Its job is to work out where that corner lands on the screen — the world, view and projection matrices from chapters 2 and 3, multiplied together.
- The fragment shader runs once per pixel covered. Its job is to decide what colour that pixel is: it is where lighting happens, and where a scene at 1080p means two million executions per frame.
This library ships one pair of them, written three times over — once in GLSL for OpenGL, once in Metal Shading Language, once as ordinary C# for the CPU fallback — and gives you no way to supply your own. That is a deliberate trade: a custom shader would have to be written three times by you as well, and would not survive the trip to a browser tab. What you get instead is a Material, which is that shader's parameters.
Normals: which way a surface faces
Light striking a surface square-on is bright; light grazing it is dim. To know which, the shader needs the direction the surface faces at that point — its normal. Normals are stored per vertex and interpolated across the triangle in between.
That interpolation is what lets a faceted sphere look round: the geometry is still flat pieces, but if neighbouring triangles agree about the normal at a shared corner, the shading crosses the seam smoothly and the eye reads a curve. Give each triangle its own normals and you get the flat-shaded look instead. Both are one call:
var smooth = mesh.WithSmoothNormals(); // corners agree — a curved surface
var faceted = mesh.WithFlatNormals(); // corners disagree — hard edges
Which one you want is a question about the silhouette, not about taste. Smooth shading on an eight-sided
cylinder reads as a badly lit tube: the shading says round while the outline says octagon,
the eye believes the outline, and the result looks like a mistake. The same geometry faceted reads as an
octagonal drum — deliberate, solid, and obviously what was meant. Low-polygon work wants
WithFlatNormals; it is the look, not a compromise.
WithFlatNormals costs vertices, and a real number of them. A hard edge means the two faces
cannot share a corner, so every triangle gets its own three: a 32-segment sphere goes from 561 vertices to
3,072. Call it once when the mesh is built. WithSmoothNormals is the way back — it welds by
position first, so a mesh that has already been split, by this call or by an STL file or by an exporter set
to flat shading, smooths as though it never had been.
A mesh with no normals is not lit. Everything in Primitives and everything
GltfLoader produces already has them. A mesh you build by hand does not until you ask.
WithGeneratedNormals is the one to call there — it fills the gap and leaves a mesh that
already has normals alone, which is what a loader wants and what the two above deliberately do not do.
The two dials
This is a metallic-roughness PBR renderer, which is the same model glTF uses and the same one every modern engine uses. It has two main parameters, and understanding them is most of understanding how anything here looks.
Metallic is not a shininess slider. It picks between two genuinely different
physical behaviours, and values in between are only meaningful at a boundary — a painted surface flaking
off to bare metal.
Metallic = 0, a non-metal: the surface has a diffuse colour — itsBaseColor— and its reflections are white, whatever colour it is. A red snooker ball has a white highlight.Metallic = 1, a metal: there is no diffuse term at all. All the colour comes from the reflection, tinted byBaseColor. This is why gold looks like gold rather than like yellow plastic — and why a metal in a black room renders black, correctly, because there is nothing for it to reflect.
Roughness is how scattered those reflections are. At 0 the surface is a
mirror; at 1 it is chalk. Everything interesting is in the middle, and the arithmetic behind it is GGX with
height-correlated Smith visibility and Schlick Fresnel — the same on all three renderers, so a material
tuned on a Mac looks the same in a browser tab.
var gold = new Material { BaseColor = new(1f, 0.77f, 0.34f, 1f), Metallic = 1f, Roughness = 0.25f };
var clay = new Material { BaseColor = new(0.75f, 0.3f, 0.2f, 1f), Metallic = 0f, Roughness = 0.85f };
Maps: varying it across the surface
One Roughness for a whole object is a uniform object. To vary anything across a surface you
need a picture of the variation and somewhere to look it up — which is what
UV coordinates are: two numbers per vertex saying where in the image that
corner sits.
BaseColorTexture |
The colour, per pixel. Multiplied by BaseColor, so the property is a tint over the
map rather than an alternative to it. |
MetallicRoughnessTexture |
Both dials in one image: roughness in the green channel, metallic in blue. That is the glTF packing, not a quirk — it lets one fetch answer two questions. |
NormalTexture |
Fake surface detail. Each pixel holds a direction rather than a colour, and the shader uses it in place of the interpolated normal — so rivets, panel seams and weave catch the light without a single extra triangle. The silhouette is what gives it away: a normal-mapped rivet has no bump on the edge of the object. |
EmissiveTexture, EmissiveColor |
Light the surface gives off itself. It is not a light source — it does not illuminate anything else — it simply stays bright where everything around it goes dark. |
OcclusionTexture |
Where ambient light does not reach: crevices, the inside of a bolt hole. Baked in advance, because working it out live is what a global-illumination renderer does. |
BumpTexture |
A height field, turned into a perturbed normal from its screen-space gradient. Cruder than a normal map and needs no tangents — which makes it the better choice on a sphere, where UV tangents degenerate at the poles. |
A normal map needs one more thing than the others: a tangent per vertex, so the shader knows which way is "right" across the surface at that point. Most exporters omit them, so the loader derives them — and you can too:
var ready = mesh.WithGeneratedNormals().WithGeneratedTangents();
And if what you have is a height field rather than a normal map — which is usually what a procedural surface produces — convert it once rather than authoring the directions by hand:
material.NormalTexture = Texture.NormalFromHeight(height, strength: 2.4f);
It reads the height as a linear value, takes the slope across each texel and writes the direction that slope
implies. strength scales the slope, so the same field can be a hint or a relief; wrapping is on
by default, because a tiling height field with a seam down one edge is worse than no relief at all.
Textures do not have to come from files. Texture.FromPixels takes raw RGBA, which for a map generated in code saves encoding a PNG only to decode it again — over a second of stall for four large maps in a browser:
var pixels = new byte[256 * 256 * 4];
// … fill it in …
material.BaseColorTexture = Texture.FromPixels(pixels, 256, 256, name: "panels");
Both factories take a filter, and the default is right nearly always.
TextureFilter.Linear blends the four nearest texels, which is what a
photograph, a normal map or a generated gradient wants — the image is a continuous thing that happens to be
stored as a grid. Nearest takes the one texel the coordinate lands in and does no blending, so
a texel magnified across many pixels stays a hard-edged square:
material.BaseColorTexture = Texture.FromPixels(
pixels, 128, 96, name: "screen", filter: TextureFilter.Nearest);
That is for the images where the grid is the content: pixel art, a palette or lookup strip, an atlas of glyphs. A 128-pixel-wide picture shown four hundred pixels across is a different picture under each filter and only one of them is the picture that was drawn. Nearest shimmers on a surface seen small and moving, which is exactly what a mip chain exists to stop, so keep it for things seen large.
A texture whose pixels change — a video frame, a minimap, a game drawn into a buffer — should keep its instance rather than be built again. Write into the array you gave it and say so:
// … rewrite the same pixels array …
texture.Refresh();
Building a new Texture each time works, and flickers. Uploads are streamed
a few per frame so that a model with twenty-five maps does not stall the first one, which means a texture
the renderer has not seen before draws as flat white until its turn comes — and one replaced ten times a
second is therefore white one frame in six. Refresh keeps the picture already uploaded on
screen right up to the moment the new pixels replace it, and allocates nothing. It applies to raw pixels
only: encoded bytes are a file, and a file that changed is a different texture.
Moving a map around the surface
A mesh's UV coordinates are fixed when the mesh is built, and they are rarely the coordinates you want. So the material carries a transform applied to them before anything is sampled:
UvScale |
How many times the map repeats. (4, 4) is sixteen tiles where there was one. |
UvOffset |
Where it starts. Move it every frame and the surface scrolls — a conveyor, a waterfall, a starfield. |
UvRotation |
Radians, turning the map on the surface. Grain along the plank rather than across it. |
UvPivot |
The point the rotation turns about, in tile units. (0.5, 0.5) — the middle — by
default. |
material.UvScale = new Vector2(6f, 2f);
material.UvRotation = MathF.PI / 4f;
The pivot applies to the rotation and not to the scale, and that is worth a sentence because it is the one thing here that surprises people. A scale is a density — it says how tightly the pattern is packed, and a density is measured from the origin, not from the middle of a tile. A rotation is a turn, and a turn needs a centre. Apply the pivot to both and doubling a tiling also slides it, which is never what anyone meant.
The same four properties are on MeshNode, and they compose on top of the material's. That is what lets one material dress a whole scene while every copy of it looks different:
foreach (var (crate, i) in crates.Select((c, i) => (c, i)))
crate.UvRotation = i * MathF.Tau / crates.Count; // one material, a phase each
Rotating the coordinates rotates the frame a normal map is read in, and the renderer corrects for it — with the rotation alone, not the scale. A normal map's texels hold directions in tangent space, and stretching the domain a map is read from does not tilt the surface it describes. (A height map is the other way about, which is why the two are not corrected the same.) Applying the scale to that correction turns a rope tiled thirty-nine times round a post into corrugated iron.
Coordinates that do not come from the mesh
Every complaint anyone has about a texture on a model comes from one place: the coordinates were decided when the mesh was made. A sphere wears a sunburst at its poles because the whole top row of texels is crushed into a point. A cylinder has a seam because a map has to stop somewhere. A box unwrapped carelessly has a face at a different scale from its neighbour. And geometry generated in code frequently has no coordinates at all.
UvSource takes the coordinates from a projection instead, which removes all of those at once — there is no unwrap to be wrong:
Mesh | The mesh's own coordinates. The default, and what a normal map's tangents assume. |
PlanarX, PlanarY, PlanarZ | Projected down one axis: one sample, no blend. A floor, a ceiling, a wall that faces one way. |
Triplanar | Projected down all three and blended by the surface normal. Three samples a map, which is the honest price — worth it on anything that faces more than one way. |
material.UvSource = UvSource.Triplanar;
material.UvDensity = 2f; // repeats per metre
material.UvSharpness = 4f; // how hard the three projections switch
Under a projection, scale stops meaning repeats-per-surface and starts meaning
UvDensity: repeats per metre. That is the quiet win. The same number on a bolt
and on a bulkhead gives both the same texel size, so texture density comes out right by construction
instead of by a line of arithmetic at every call site. UvScale still applies on top, so a
projected material can be stretched as well.
UvSharpness is the exponent the surface normal is raised to before the three triplanar weights
are normalised: one is a wide soft blend showing all three projections across most of a curve, eight is
nearly a hard switch at each forty-five degree line. Four suits hard-surface geometry; go lower for
something organic.
UvSpace decides what the projection is measured in.
Object is the default: the texture sticks to the geometry and does not swim when the object
moves. World makes two identical objects at two positions read different parts of the texture,
which breaks up a repeat for free — and makes anything that moves swim, which is exactly why it is not the
default.
A projected normal map has no tangents to use, so the shader builds a frame from the surface normal instead. That is exact on a planar projection and has a seam on a ball where the dominant axis changes. It is the trade that buys you a normal map on geometry that was never unwrapped.
The layer underneath everything
There is a resolution a tiling map cannot reach, and it is not a shading problem — it is a memory one. A map has to carry a surface's colour, its pattern and its identity at whatever resolution the budget allows. Raise every material far enough to also carry the grain of the metal and you have multiplied the whole texture budget to add something that is the same on every surface in the scene.
So carry that end of the range once, for the whole scene:
scene.Detail.Texture = Texture.DetailField(512, octaves: 5, persistence: 0.55f, seed: 1);
scene.Detail.Density = 200f; // repeats per metre — a fifth of a millimetre a repeat
material.DetailNormal = 0.6f; // how much it perturbs the normal
material.DetailRough = 0.3f; // and the roughness
One texture, bound once, sampled at a frequency taken from position rather than from any mesh's
coordinates, perturbing the normal and the roughness underneath whatever the base maps already say. The base
maps go back to doing what they are good at. Scene.Detail holds the
field and the default density; each material says how much of it it wants, through
DetailNormal, DetailRough, DetailTint and its own
DetailScale.
A detail layer has to fade, because a fifth of a millimetre seen from ten metres is smaller than a pixel and
a feature smaller than a pixel is noise. DetailFade is the distance over which it goes away —
and where it does, the strength moves into roughness rather than simply vanishing, so a surface does not
turn glassy as you walk backwards.
It is per scene, not per material, and that is the whole economy of it. A detail map on every material would put back the memory this exists to remove and spend a sampler unit per material rather than one for the lot.
Lights
As many as you like, in Scene.Lights, of three kinds — plus an environment that is not one of
them and costs nothing whether there is a light in the scene or not.
scene.Lights.Clear();
scene.Lights.Add(new DirectionalLight
{
Direction = Vector3.Normalize(new(-0.4f, -0.8f, -0.4f)), // the sun: a direction, no position
Color = new(1f, 0.96f, 0.9f),
Intensity = 3f
});
scene.Lights.Add(new PointLight
{
Position = new(0f, 2f, 0f), // a lamp: a place, and a reach
Color = new(1f, 0.6f, 0.2f),
Range = 12f, // dead at 12 units; 0 means no limit
Decay = 2f // inverse square, like the real thing and like three.js
});
scene.Lights.Add(new SpotLight
{
Position = new(0f, 3f, 0f), // a torch: a place, a direction, and a cone
Direction = -Vector3.UnitY,
Range = 14f,
InnerConeDegrees = 14f, // full brightness inside this half-angle
OuterConeDegrees = 26f // nothing outside this one; between them it fades
});
The two cone angles are half-angles from the axis, which is what glTF, Blender, three.js and Godot all mean by them — so a light tuned in any of those arrives here meaning the same thing. A twenty-six degree outer cone is a fifty-two degree beam. Set both to the same number for a hard-edged circle, which is what a slide projector is.
A torch is a spot light, not a point light with a torch drawn on it. The difference is not the beam — you could fake that — it is the shadow. Read on.
How many is sensible. Sixteen. Not a limit —
Scene.Lights takes what you give it, and on either GPU renderer
sixteen lights cost no measurable frame time over one. The number is the CPU renderer's: it shades once
per vertex rather than per pixel, so each light is another pass over every vertex in the scene, and
sixteen of them costs about a third of its frame rate. LightCollection.Capacity is the
figure, and it is also the fewest OpenGL will ever draw — that backend builds its light loop for
whatever the driver it started on has room for, which on current hardware is far more.
EnvironmentLight is the fill: the light that comes from everywhere, so a metal has something to reflect. It is why a metal here does not come out black. By default it is an analytic two-colour hemisphere, sky above and ground below, which costs nothing, needs no assets and runs on a phone.
scene.Environment = EnvironmentLight.Studio(0.6f); // brighter, neutral
scene.Environment = EnvironmentLight.None; // nothing but your own lights
Give it an image instead and the fill becomes a place. Texture takes an equirectangular
picture — u once round the horizon, v from straight up to straight down, which is exactly what
Primitives.Sphere maps — and Rotation turns it. That
layout is the reason one image can be both the sky a viewer sees and the light coming off it, so the
reflection in a hull is the backdrop rather than something that resembles it.
scene.Environment = EnvironmentLight.FromTexture(sky, intensity: 1.15f);
scene.Environment.Rotation = MathF.PI * 0.25f; // turn the sky, not the geometry
The image is blurred once, into eight progressively wider copies, and a surface reads whichever two its roughness falls between. That is what makes a polished hull reflect the sky sharply and a brushed one reflect an average of everything within twenty degrees — from one image, with no cubemap, on all three renderers including the one with no GPU. It costs under ten milliseconds on the frame the image first appears, and nothing at all on a scene that never sets it.
Eight bits a channel. A Texture cannot carry a sun ten thousand times
brighter than the sky the way an HDR probe can. Put the sun in a
DirectionalLight, where it belongs, and let the image carry
the rest.
Lights are not nodes. The scene cannot see you changing one's fields, so call
scene.Invalidate() after you do — adding or removing through Scene.Lights does
it for you. Same rule as a Material, and for the same reason.
An environment image as described here is eight bits a channel, so the sun in it is no brighter than a cloud. For an HDR image that keeps it bright, and for rooms that each reflect their own surroundings, see Interior lighting and HDR.
Shadows
A light on its own lights everything it can see and everything it cannot. Nothing in the arithmetic above knows that a table stands between the lamp and the floor. A shadow is that knowledge, and it is bought separately: pick one light, and the scene is drawn once more from where that light is, into a shadow map of how far away the nearest surface is in every direction. Shading a pixel then asks the map whether something stood between it and the light. If so, that light is taken away and everything else — the environment, every other lamp — stays.
scene.Lights[0].CastsShadows = true; // this one
scene.ShadowMapSize = 2048; // the default; 1024 for a turntable, more for a corridor
scene.ShadowStrength = 1f; // 1 takes all of that light away; 0.7 leaves some bounce
scene.ShadowBias = 0.0015f; // the default; raise it if a lit surface stripes itself
scene.ShadowsEnabled = false; // the checkbox: off and back on, with nothing else forgotten
One light casts, unless you ask for two. Setting CastsShadows on two does
not buy two shadows by itself: the first in Scene.Lights wins, and
Scene.ShadowCastingLight says which. Raising
Scene.MaxShadowedLights to two lets a second one cast as well — see
Interior lighting and HDR. Either way, which light you pick
decides how good the shadow can be, so the three kinds are worth telling apart.
A DirectionalLight is the sun: parallel rays, a box fitted round what casts, and a shadow the same size as the thing casting it. Pick it whenever the scene has a sun.
A SpotLight is the best-behaved of the three, because its shadow is its own beam. There is nothing to fit and nothing to guess: the map covers exactly what the cone covers, at the resolution the cone's own angle implies, and everything outside it is unlit rather than lit as though nothing blocked it. Anything a character carries — a torch, a headlamp, a lantern — should be one of these.
A PointLight is a bulb, and a bulb has no direction at all — so it gets six maps rather than one: a cube, with a face looking along each axis. It shadows in every direction, and there is nothing to aim and nothing that can be pointed the wrong way.
Six faces is not six times the work. Each face is a ninety-degree view and sees about a sixth of what surrounds the light, so the triangles drawn across all six come to roughly one full view of the scene. What is genuinely sixfold is the setup, and a depth-only pass is the cheapest one there is. It also makes the lookup cheaper than a single map's: sampling a cube takes a direction and the hardware picks the face, so there is no matrix, no clip-space divide and no test for falling off the edge.
The map is fitted to what casts rather than to what is visible, and every MeshNode casts
unless told otherwise. Tell the two that should not:
ground.CastsShadow = false; // a floor casts nothing anybody can see, and a big one costs the map its sharpness
sky.CastsShadow = false; // a backdrop surrounds everything, and would spread the map over the whole sky
Both still receive. The flag is about what goes into the map, not about what is shaded by it.
One map has one frustum, so its sharpness is set by the largest thing casting anywhere in the scene — including in rooms nobody can see. A ship forty metres long spreads its map over forty metres and the cabin you are standing in gets the few texels that fall in it. When only part of a scene needs to cast, say so:
scene.ShadowCasters = currentRoom; // only this subtree; null is the whole scene
One assignment, changed as the viewer moves, instead of writing CastsShadow to every mesh
in the building whenever a door opens. The frustum shrinks to what is left and the texels shrink with
it. Nothing stops receiving — the depth range still reaches everything a shadow could land on.
Ask what you got. A shadow map that is technically working and practically useless looks exactly like one that is fine, because a bad shadow is still a shadow. RenderInfo.ShadowSummary says in one line which shape was fitted, how large one of its texels is in the world, and — the two worth knowing — whether a positioned light's cone had to be aimed by guesswork, and whether the light sat inside the things it was shadowing and opened the cone as wide as it goes. Both of those are fixable, and neither is visible.
Two things a shadow does wrong, and the knob for each. Bands of shadow crawling across
a surface that faces the light are the surface shadowing itself: raise ShadowBias, a
little at a time. A shadow floating free of the thing casting it is the opposite fault: bring the bias
back down, or give the map more pixels. Neither has a value that suits every scene, because both errors
scale with how much world the map covers.
The CPU renderer shades per vertex, so its shadow is only as sharp as the mesh it lands on. A floor of four vertices interpolates a whole scene away; a floor divided ninety-six times draws it. The GPU renderers shade per pixel and do not care.
Four terms that are not physics
PBR describes surfaces lit by light. Some things in a scene are not that, and pretending otherwise costs more than a switch:
Unlit— base colour straight through, no lighting, no tone map. A sun disc is a flat bright circle, not a lit sphere with a terminator across it.RimColor/RimPower/RimIntensity— a Fresnel rim, brightest where the surface turns away from you. An atmosphere seen from space is a shell with a rim on it, andRimLightBiaskeeps the glow strongest on the sunward limb.EmissiveNightSide— masks the emissive map to the hemisphere facing away from the key light, so a planet's cities stop glowing through its own daylight.Cull—Back,FrontorNone. Back is the usual saving;Frontis what a sky sphere seen from the inside needs.DoubleSidedis separate and independent:Culldecides which faces are drawn,DoubleSideddecides how the ones that are get lit.
The pixel contract
Every image that crosses this API — a texture you build, a frame you capture, a picture the offline renderer hands back — is one of four things, and each is described here once so that a test can check it against a number rather than a guess. Rows run top to bottom, the first byte of the array being the top-left pixel; alpha is straight, never premultiplied; and a colour is either display-encoded (sRGB, what a screen is sent) or linear (what the lighting arithmetic works in).
| Image | Bytes | Encoding |
|---|---|---|
A raw texture — Texture.FromPixels, or Pixels set by hand |
RGBA8, tightly packed, Width × Height × 4; top row first; straight alpha |
sRGB in a colour slot (base colour, emissive, matcap), linear in a data slot (metallic-roughness, normal, occlusion, bump) — unless ColorSpace says otherwise |
A captured frame — CaptureAsync, or AVA3D_CAPTURE |
RGBA8 or PNG: top row first, straight alpha, whichever way the backend's own framebuffer ran;
RgbaF16 for the linear frame under LinearHdr |
Display-encoded — what the screen showed, bloom and vignette included |
A linear-HDR frame — Scene.LinearHdr |
RGBA16F inside the renderer, linear light, premultiplied; nothing hands that surface out | A capture of it is taken after the HDR processor, so it is display-encoded like any other; the F16 values are not observable through this API |
| OfflineRenderer's result | An RGBA8 Texture: top row first, straight alpha, ready to put on a material or save |
Display-encoded, composited by default; Complete and
SkippedTriangles say whether all of it was drawn |
The slot rule is what makes a glTF file look right without a flag on every image: colour maps are stored sRGB, data maps are stored as numbers, and every shader decodes the first kind and not the second. Two textures a program can make break it. A colour computed in linear light and written straight from a float — a gradient, a ramp, a noise field — is not sRGB, and read as sRGB it comes out darker than the numbers you wrote. A photograph used as an occlusion map is sRGB, and read as data it occludes too little. Say which on the texture and the slot's rule is overridden for it, on every renderer:
var ramp = new Texture
{
Pixels = bytes, Width = 256, Height = 1,
ColorSpace = TextureColorSpace.Linear // these bytes are linear light; do not decode them
};
It is honoured where the image is decoded rather than in the shaders: the bytes are re-encoded once, on upload or on decode, into what the slot expects. An eight-bit linear image through a colour slot therefore keeps eight bits of sRGB, which is coarser in the darks than the linear original — for values that must survive exactly, keep the map linear and read it from a data slot.
What the offline renderer draws is the software renderer, and the software renderer keeps a triangle
budget so a live frame cannot hang the UI thread. Offline there is no frame to be late for, so the budget
is yours to set — OfflineRenderOptions.TriangleBudget —
and the result says what it left out rather than leaving you to notice. The live budget is
SoftwareSettings.TriangleBudget, and a live frame reports the
same numbers through RenderInfo.SkippedTriangles and SkippedItems.
var result = OfflineRenderer.Render(scene, camera, 1024, 768, new OfflineRenderOptions
{
Quality = OfflineQuality.Shaded, // per pixel, every map read — the GPU picture, slowly
TriangleBudget = 2_000_000 // whole, however long it takes
});
if (!result.Complete)
throw new InvalidOperationException($"{result.SkippedTriangles} triangles missing");
Run it
The chart is metallic across and roughness down, one colour, no textures — read the corners. The full PBR scene is all five maps on one object, generated in code from a single height field. Shadows is the feature in two lines of scene code; Clock tower is the case they were written for — one window, one sun, a room drawn on its own floor — and once a minute or so a cloud hands the map to the lamp over the wheels, so both kinds of shadow can be seen in one place.
AVA3D_SCENE="PBR chart" dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE="Full PBR" dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE="Four lights" dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE=Shadows dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE="Clock tower" dotnet run --project samples/Ava3D.Demo.Desktop
And the three scenes for this half of the chapter: the same material on the mesh and projected, the detail layer fading with distance, and forty crates wearing one material forty ways.
AVA3D_SCENE=Projection dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE=Detail dotnet run --project samples/Ava3D.Demo.Desktop
AVA3D_SCENE=UvVariation dotnet run --project samples/Ava3D.Demo.Desktop