Renderer · interface

SurfaceMaterial

Everything the flat pass needs to know about a surface, in one call.

Explained in Hello world.

interface SurfaceMaterial<Texture = SurfaceTexture>
import type { SurfaceMaterial } from '@driftengine/core';

In depth

Shaped for what comes after it. ORM is the next map and emissive the one after; both are a field here rather than a fourth and fifth setter, which is the whole reason this replaced setSurfaceTexture rather than sitting beside it. A consumer setting four maps in four calls is four chances to forget one, and four resets bindMeshPass has to reason about.

Every field is independent. A material with a normal and no albedo is a legitimate one — vertex colour with authored normals — and shades as such rather than as no material at all.

Generic over the texture, for the reason SurfaceTextureHandle exists. The two backends have different texture classes and the public API speaks in an opaque handle; parameterising here lets each renderer take its own concrete type while a consumer sees one shape. Defaulted to the WebGL2 class so the common spelling stays SurfaceMaterial.

Properties

NameTypeDescription
albedooptionalTexture | null
normaloptionalTexture | nullSurface-space directions, turned into world space through the mesh's tangent frame.
More

Read by the flat pass since 2026-08-22, from the attribute at location 10 where the mesh has one and from a per-fragment cotangent frame where it does not. normalStrength gates it. See docs/RENDERING.md for the normal-map path.

ormoptionalTexture | nullOcclusion in R, roughness in G, metallic in B, in one image.
More

glTF's packing, because that is what an import carries, and one unit for three channels rather than three units for three maps. Sampled linear and never sRGB: these numbers are the data.

emissiveoptionalTexture | nullWhere a surface glows, and in what colour.
More

It modulates the surface's own emission rather than creating it, which is glTF's rule — emitted colour is emissiveFactor * emissiveTexture — and is the one thing about this map that surprises people. A mesh whose emissive attribute is 0 emits nothing however bright the image it binds, because there is nothing for the image to scale. An import carries the factor for you: gltf.ts puts max(emissiveFactor) in the attribute and the factor itself in emissiveColor. A procedurally built mesh has to say so the same way it says its colour, which is in vertex data, because that is what makes a whole world one draw call here.

Sampled sRGB, unlike normal and orm: this one is a colour a person picked, where those two are numbers. glTF says the same.

uScaleoptionalnumberRepeats across the mesh's own UV range, per axis. Applies to every map the material holds.
vScaleoptionalnumber
uOffsetoptionalnumberWhere the texture starts, per axis, added after the scale: a surface samples uv · scale + offset. Applies to every map, and to a cutout's shadow and depth as well.
More

What picks a cell of a flipbook or an atlas through the material, so one quad draws any cell: a strip of four frames is uScale: 0.25 and uOffset: frame * 0.25. Without it a consumer built one quad mesh per cell — 106 meshes for four strips — because the only way to move a texture was to move the geometry's own coordinates.

vOffsetoptionalnumber
cutoutoptionalnumberAlpha below which a fragment is discarded. See uCutout.
cutoutModeoptionalCutoutModeHow the cutout's edge is drawn: 'hard', the default and every cutout before 4.8.4, or 'dithered', which keeps a pixel by the share of it the texture covers so a strand of hair, a lash or a fringe of leaves has a soft edge instead of a stair of pixels.
More

The frame decides how a dithered edge is resolved, because the material cannot know: under a temporal resolve (TAA or DriftTR) the pattern moves every frame and the resolve averages it; multisampled, the share goes to the GPU as alpha-to-coverage; with neither, the test is hard, since a dither nothing averages is grain. A translucent draw tests hard whatever this says. See cutoutDither.ts.

doubleSidedoptionalbooleanWhether both faces are seen: glTF's doubleSided, what a curtain or a leaf card is. Drawn without culling, and a back face is lit as its front, with the normal turned to the viewer.
lightChannelsoptionalnumberThe lighting channels this surface takes light from, a mask: 1, 2, 3 … 255. 1 by default, which is every light's default too, so a scene that names no channel is lit as it always was.
More

A point or spot light shades this surface only where its PointLightSet.lightChannels and this share a bit — lighting channels. A character's own key and rim lights on channel 2, and the character's materials on 3, light the character and leave the floor around it as the stage's lights alone have it. What it does not reach: the sun, the sky and the probes, area lights, DriftLight's volume and the GPU-driven pipeline light every surface whatever its mask. A number that is not a whole mask from 1 to 255 is channel 1.

normalStrengthoptionalnumberHow hard the normal map turns the shading normal.
More

Defaults to 1 when a normal is supplied and 0 when it is not, because binding a map and saying nothing about strength means "use it" rather than "use it at nothing".

roughnessScaleoptionalnumberScales the ORM map's G channel. glTF's roughnessFactor. Default 1.
More

The map replaces the mesh's own roughness attribute rather than scaling it, and this is what gives back the scaling. Scaling the attribute was the alternative and it is wrong outside an import: vertexDefaults.ts hands an absent roughness 0.4277, not 1, so every procedurally built mesh would read far glossier than the image it was handed, with nothing in the API to say it would.

metallicScaleoptionalnumberScales the ORM map's B channel. glTF's metallicFactor. Default 1.
occlusionStrengthoptionalnumberHow much of the ORM map's R channel to apply. glTF's occlusionTexture.strength. Default 1.
More

0 is no occlusion, not full occlusion. glTF defines it as 1 + strength * (texel - 1), so the shader mixes from 1 rather than multiplying — a multiply at 0 would black the surface out, which is the opposite of what the caller asked for.

reflectivityoptionalnumberHow much of the environment this material's draws mirror, 0 to 1: the number setSurfaceReflectivity sets for every draw, stated by the material for its own. Absent, its draws wear the setter's, as every material's did before this existed.
More

Here so a static list can carry it. A list keeps each entry's material and none of the renderer's state between draws (createStaticDraws), so a stage whose batches each mirror their own share of a baked reflection says so in the material it records. What it gives up is nothing a setter could do: one called after this material is set is ignored for the material's draws.

environmentGainoptionalnumberHow bright the environment this material's draws reflect is: setEnvironmentGain's number, stated by the material for its own, and absent the setter's. Held non-negative.
emissiveScaleoptionalreadonly [number, number, number]Scales the emissive map, per channel. Default 1.
More

The counterpart of roughnessScale and metallicScale: a factor that multiplies a texture is not a value, and this is where the multiplier lives once an image supplies the shape. A consumer who wants a bound map twice as bright asks here rather than rebuilding the mesh.

modeloptionalSurfaceModel | nullHow the surface answers light, when the standard model is not what it is made of: brushed metal, hair, skin, an eye — anisotropicModel, hairModel, skinModel, eyeModel. Null or absent is the standard model, exactly as before. A pipeline of its own, compiled the first time a draw asks, so a scene that names no model pays for none. See surfaceModel.ts.
modelMapoptionalTexture | nullThe model's own channels, in the albedo's coordinates and layers; what each channel means is the model's to say. Absent, each model takes its own neutral. Sampled linear, like any map that is not a colour.
physicalSpecularoptionalbooleanWhether a lamp's and the sun's highlight on this surface is GGX's own, π · D · Vis · F · N·L, as physically based renderers and this engine's skin and eye shade it, rather than the engine's lobe scaled to a peak of one. False by default, every surface as it was.
More

The peak-normalised lobe is a look control: the specular attribute says how strong a highlight is and roughness how wide, apart. Physical, they are entangled as they are in a real surface — the peak is 1 / (4 α²) of the light head-on, so a polished surface's highlight is many times the look's and a rough one's lower and broader — and the specular attribute is read as the reflectance at normal incidence, F0: 0.08 × Specular, 0.04 at its default. Fresnel then brightens every surface toward grazing, not only a metal. A rectangle's highlight is the lobe integrated over it with the attribute as F0 already, so it is unchanged either way.

On the standard, lightmap and anisotropic models. Skin and the eye are physical already; hair keeps its own three lobes. What it gives up: the look's independence of strength and width, and an 8-bit frame clips the brighter peak without hdrScene.

projectionoptionalSurfaceProjection | nullTexture coordinates from where a point is in the world rather than from the mesh: 'planar' across the horizontal axes for ground, 'triplanar' on three planes for walls and rock, at so many repeats a metre. Absent, the mesh's, as every material's were. A lit switch, compiled in the first time a material asks, so a scene that never asks pays nothing. See surfaceProjection.ts for what it gives up.
diffuseTransmissionoptionalnumberHow much light from behind a thin surface lets through, 0 to 1: a banner lit from behind, a leaf against the sun, a lampshade. The light falling on the far side — the sun, lamps, area lights, DriftLight — reaches the eye through the surface, coloured by its own colour; a metal lets none through. 0, the default, is every material as it was. Two-sided or not: a one-sided surface seen from its front shows the light behind it as well.
More

What it gives up: a thin surface, not a volume, so a thick one lets through as much as a sheet; the light behind is not blurred by the surface; and none of it on glass, which lets light through by its own rule (TranslucentMeshOptions.glass).

transmissionColoroptionalreadonly [number, number, number] | nullThe colour the light from behind takes through the surface, linear, in place of its own colour: a banner whose cloth glows a flat red behind a printed face, a leaf whose veins are not what the light through it shows. Absent, the surface's colour, as diffuseTransmission has always used. Each component is held at zero or above. Read only where diffuseTransmission lets light through.
layersoptionalSurfaceLayers<Texture> | nullUp to five layers blended by a mask, each at its own repeat: albedo, normal and orm arrays whose layers are the material's, and a mask laying each over the ones before it or summing them, from a map, the ORM array or the vertex colour; under a projection the layers are placed by the world. Absent, the material is one layer, as every material was. A lit switch, compiled in the first time a material asks. A mask that is a map is read where modelMap goes, so such a material carries one or the other; a lightmapped one keeps its mask in its ORM array or its vertices. See surfaceLayers.ts for the rest and what it gives up.