Skip to content

Render pixel art without fractional scaling

Coordinate texture filtering, an integer-scaled orthographic camera, object snapping, and DPR.

Pixel art shimmers when texture filtering, camera scale, and object placement use different pixel grids. The default pixel-art preset coordinates all three.

Flatland creates a managed PixelPerfectCamera. Sprite2D and TileMap2D snap their final projected pivots to the same physical-pixel grid, and Flatland loaders use nearest-neighbor filtering.

const flatland = new Flatland({ viewSize: 240 })
flatland.render(renderer)

No preset assignment is required. FlatlandConfig.options defaults to 'pixel-art'.

PixelPerfectCamera extends Three.js’s OrthographicCamera. viewSize is the minimum visible vertical resolution in world units; one world unit represents one source pixel. With only viewSize, the camera reveals more world space as needed to fill the output at an integer scale.

import { Scene } from 'three';
import { WebGPURenderer } from 'three/webgpu';
import { PixelPerfectCamera, Sprite2D } from 'three-flatland';
const scene = new Scene();
const camera = new PixelPerfectCamera({ viewSize: 240 });
const renderer = new WebGPURenderer({ antialias: false });
function resize() {
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
renderer.setSize(innerWidth, innerHeight);
camera.setDrawingBufferSize(renderer.domElement.width, renderer.domElement.height);
renderer.setViewport(camera.getLogicalViewport(renderer.getPixelRatio()));
}
resize();
addEventListener('resize', resize);
scene.add(new Sprite2D({ texture }));
renderer.render(scene, camera);

The React hook installs the camera as the R3F default, updates it after canvas or DPR changes, synchronizes R3F’s derived viewport values, and restores the previous camera when it unmounts. Its event adapter excludes the letterbox region from pointer hits.

When a Flatland instance owns the camera and renders its internal scene manually, bind that existing camera instead of creating another one:

import { useCallback, useState } from 'react'
import { Flatland, PixelPerfectCamera, usePixelPerfectCameraBinding } from 'three-flatland/react'
function Scene() {
const [camera, setCamera] = useState<PixelPerfectCamera | null>(null)
usePixelPerfectCameraBinding(camera)
const bindFlatland = useCallback((flatland: Flatland | null) => {
const next = flatland?.camera
setCamera(next instanceof PixelPerfectCamera ? next : null)
}, [])
return <flatland ref={bindFlatland} viewSize={180} viewWidth={320} />
}

This connects one camera to R3F’s viewport and event state. It does not create a second camera or renderer.

Convert canvas coordinates through the camera before calling a Three.js Raycaster:

const rect = renderer.domElement.getBoundingClientRect()
const logicalWidth = renderer.domElement.width / renderer.getPixelRatio()
const logicalHeight = renderer.domElement.height / renderer.getPixelRatio()
camera.getNormalizedDeviceCoordinates(
((event.clientX - rect.left) / rect.width) * logicalWidth,
((event.clientY - rect.top) / rect.height) * logicalHeight,
renderer.getPixelRatio(),
pointer
)
if (Math.abs(pointer.x) <= 1 && Math.abs(pointer.y) <= 1) {
raycaster.setFromCamera(pointer, camera)
}

After a custom controller pans the camera, call camera.snapPositionToPixelGrid(). At 3× this rounds X/Y movement to ⅓-world-unit steps, moving the presentation by one physical pixel.

Add viewWidth when the whole design has a fixed width and height. The camera then preserves that exact design extent and centers it with letterbox or pillarbox bars when the output shape does not match:

const camera = new PixelPerfectCamera({
viewSize: 180,
viewWidth: 320,
})
const flatland = new Flatland({
viewSize: 180,
viewWidth: 320,
})

Automatic mode chooses the largest integer scale that fits. A 320 × 180 design in an 800 × 720 framebuffer renders at 2× inside a centered 640 × 360 viewport. A 3× scale is as exact as 2× or 4×; powers of two are an optional zoom style, not a pixel-accuracy requirement.

Set pixelScale when the scale itself is authored:

camera.pixelScale = 4

Three.js controls can also assign camera.zoom and call updateProjectionMatrix(). PixelPerfectCamera preserves that API, but rounds the resulting physical-pixel scale to the nearest positive integer. Read resolvedPixelScale for the scale actually rendered; use pixelScale when an exact authored scale matters.

The minimum automatic scale is 1×. A framebuffer smaller than the design crops the edges instead of downsampling source pixels.

Pixel-perfect math follows the physical drawing buffer. Renderer DPR remains under application control:

renderer.setPixelRatio(devicePixelRatio) // Native HiDPI buffer
renderer.setPixelRatio(1) // One buffer pixel per CSS pixel
renderer.setPixelRatio(0.5) // Half-size buffer

Use <Canvas dpr={...}> for the same control in React. An effect’s resolutionScale changes only resources owned by that effect; it does not change camera framing. Flatland’s automatic post-processing pipeline preserves the centered camera viewport. A resolutionScale below 1 is still an explicit resampling step, useful for performance or a coarser pixel effect rather than exact 1:1 post-processing.

For a render target, pass its texel dimensions to setDrawingBufferSize. Flatland does this automatically for its own render target.

Use the smooth preset when continuous subpixel motion or arbitrary zoom matters more than source-pixel alignment:

FlatlandConfig.options = 'smooth'

To keep nearest-neighbor textures while disabling presentation snapping:

TextureConfig.options = 'pixel-art'
RenderingConfig.options = 'smooth'

An individual sprite or tilemap can opt out with pixelPerfect: false. This is also the correct setting for sprites rendered through a perspective camera: their screen-space scale changes with depth, so a single scene-wide pixel grid cannot remain stable.

new Flatland({ pixelPerfect: false }) switches only Flatland’s managed camera. It deliberately does not override independently configured sprites or tilemaps. Use FlatlandConfig.options = 'smooth' when the whole rendering hierarchy should opt out together.

Perspective scale changes with depth, so one camera cannot keep every plane on a single pixel grid. PixelPerfectCamera is orthographic. Its zoom is intentionally quantized; switch presentation to smooth for continuous eased zoom.

Centered sprites with an odd physical width or height place their outer edges on half-pixel boundaries. Prefer even dimensions for a centered anchor. If an odd-sized asset must retain a centered pivot, opt that sprite out of projected snapping and author its half-pixel placement explicitly.

See the PixelPerfectCamera API reference, FlatlandOptions, and Sprite2D for the complete option and property surfaces.