flutter3d
Showcase Changelog 38 packages API reference

Normal maps

since 0.1.0 or earlier Shading and materials

A normal map fakes relief. Instead of building thousands of triangles for every bump, you store in a texture which way the surface faces at each point, and the light is computed as if the bumps were there. The ball on this page is a smooth sphere that looks cobbled.

Step 1: Write the directions into a texture #

Each texel holds a direction as three numbers from -1 to 1, stored as red, green and blue from 0 to 255. Flat ground is the colour (128, 128, 255), which is why normal maps look blue. Here the code makes round cobbles in a grid, with a flat gap around each one.

/// A grid of round cobbles. Each texel stores the direction the surface
/// faces there, x, y and z scaled from -1..1 into 0..255.
ByteData _cobbleNormals() {
  final Uint8List bytes = Uint8List(_size * _size * 4);
  const int cell = _size ~/ _cobbles;
  for (var y = 0; y < _size; y++) {
    for (var x = 0; x < _size; x++) {
      final double u = ((x % cell) + 0.5) / cell * 2.0 - 1.0;
      final double v = 1.0 - ((y % cell) + 0.5) / cell * 2.0;
      final double r2 = u * u + v * v;
      final Vector3 n = r2 < 0.85
          ? Vector3(u * 0.9, v * 0.9, math.sqrt(1.0 - r2 * 0.81)).normalized()
          : Vector3(0.0, 0.0, 1.0);
      final int at = (y * _size + x) * 4;
      bytes[at] = ((n.x * 0.5 + 0.5) * 255).round();
      bytes[at + 1] = ((n.y * 0.5 + 0.5) * 255).round();
      bytes[at + 2] = ((n.z * 0.5 + 0.5) * 255).round();
      bytes[at + 3] = 255;
    }
  }
  return bytes.buffer.asByteData();
}

In a real project this texture comes from a file. Building it here keeps the page self contained.

Step 2: Upload it with its mip chain #

createTextureFromPixels puts the pixels on the device. MipChain.build makes the smaller copies that the sampler uses when the surface is far away or turned from the view, so the bumps fade instead of shimmering.

final ByteData pixels = _cobbleNormals();
_normalMap = context.device.createTextureFromPixels(
  width: _size,
  height: _size,
  format: TextureFormat.r8g8b8a8UNormInt,
  pixels: pixels,
  mipLevels: MipChain.build(pixels, _size, _size),
)!;

Step 3: Give the map to a material #

Material.normal takes the texture and normalSampler says how to read it. normalScale multiplies the sideways part of every direction: zero flattens the relief, one is the map as painted, and larger values exaggerate it.

_ball = Material(
  name: 'cobbles',
  baseColor: Vector4(0.62, 0.6, 0.56, 1.0),
  roughness: 0.55,
  normal: _normalMap,
  normalSampler: SamplerOptions.trilinearRepeat,
  normalScale: normalScale,
);

Step 4: The mesh needs tangents #

A direction stored in a texture is measured against the surface, so each vertex needs a tangent that says which way the texture's x axis runs. The engine's shapes already carry them. withGeneratedTangents adds them to a mesh that has positions, normals and texture coordinates but no tangents, which is what a model loaded from a plain OBJ file looks like.

final MeshData data = SphereShape(segments: 64, rings: 32)
    .build(layout: VertexLayout.positionNormalTexcoord)
    .withGeneratedTangents(target: VertexLayout.standard);
final MeshNode ball = MeshNode(
  DeviceMesh.upload(context.device, data),
  _ball,
  name: 'ball',
);

Step 5: Change it while it runs #

Drag Normal scale and the relief grows and vanishes. Turn the Normal map switch off to see the bare sphere, and drag Sun direction to sweep the light across the cobbles: the bumps light on one side and darken on the other, which is what tells your eye they stand out.

_ball
  ..normal = useMap ? _normalMap : null
  ..normalScale = normalScale;
_sun.setLocalForward(Vector3(-math.sin(sunTurn), -0.3, -math.cos(sunTurn)));