Computer Graphics (rasterisation, ray tracing, WebGPU) Module 4 – 2D transformations, viewing and clipping
Window-to-viewport mapping and coordinate systems
Map a world window to viewport pixels with a scale and a shift per axis, flip y, keep shapes undistorted, change frames and size canvases for the screen.
What you will learn
- Map world-window coordinates to viewport and device pixels, including the flip of y
- Choose between stretching, letterboxing and cropping when the window and the viewport differ in shape
- Change between nested coordinate frames with frame matrices and their inverses
- Size a canvas bitmap with devicePixelRatio so that it renders sharply on high-density screens
Before you start
What you’ll learn · Before you start
What you will learn
- Map world-window coordinates to viewport and device pixels, including the flip of y
- Choose between stretching, letterboxing and cropping when the window and the viewport differ in shape
- Change between nested coordinate frames with frame matrices and their inverses
- Size a canvas bitmap with devicePixelRatio so that it renders sharply on high-density screens
Before you start
On this page
A chart’s data runs from x = −2 to 2 and y = −1 to 1, and it has to fill a box of 400 by 200 pixels whose rows are counted downwards from the top. Choosing the pixel for each point is the window-to-viewport mapping, and the same few lines decide whether a circle stays round when the box turns square, whether a wheel’s rim follows its car, and whether the drawing is sharp on a phone that packs two or three device pixels into every CSS pixel.
You will map one window onto three viewports and follow a point through the viewing pipeline, keep shapes true by letterboxing or cropping, move points between nested frames and back, follow WebGPU’s own viewport from normalized device coordinates to pixels, and size a canvas bitmap for the screen. Every number comes from a program that runs on this page.
A window and a viewport: one scale and one shift per axis
The window: The rectangle of the world, in world units, that a view shows. Points inside it are mapped to the viewport; the parts of shapes outside it are clipped. is the rectangle of the world that a view shows, here x from −2 to 2 and y from −1 to 1, in the world’s own units. The viewport: The rectangle of the screen or framebuffer, in pixels, that a view is drawn into. The window-to-viewport map takes the window onto it. is the rectangle of the screen it is drawn into, here 400 by 200 pixels with its origin at the top left. Mapping one onto the other takes a scale and a shift on each axis:
In words: measure how far the point is from the window’s left edge, turn that distance into pixels, and start from the viewport’s left edge; for y, measure down from the window’s top edge, because screen rows are counted downwards. Here both scales are 400 ÷ 4 = 200 ÷ 2 = 100 pixels a unit, so the point (1, 0.5), 3 units from the left edge and 0.5 below the top, lands at pixel (300, 50):
The window of the world and the viewport it is drawn into
Text description of the diagram
Two panels joined by an arrow.
The left panel, "World, y up", shows faint x and y axes and the window: a rectangle from (−2, −1) at its bottom left to (2, 1) at its top right, twice as wide as it is tall. A dashed unit circle sits at its centre, touching its top and bottom edges. A blue dot marks the point (1, 0.5), up and to the right of the centre.
The right panel, "Viewport, y down", shows the viewport, 400 by 200 pixels, with (0, 0) at its top left corner and (400, 200) at its bottom right. The dashed circle is still a circle, because both axes are scaled by 100 pixels a unit, and the blue dot is at pixel (300, 50): three quarters of the way across and one quarter of the way down, because y is counted downwards.
Under the panels the map is written out: x_v = 100 (x + 2) and y_v = 100 (1 − y).
That map is the end of the 2D viewing pipeline: The stages that take a point from its own shape's coordinates to a pixel: modelling coordinates, world coordinates, the window, normalised coordinates and the viewport., which takes a point from the shape it belongs to all the way to a pixel. Step through it with the same point:
The drawing shows the whole scene; its 6 steps are listed below.
Every step as text
- The shape's own frame. A dot is drawn at the origin of its own shape, (0, 0). Modelling coordinates are whatever is convenient for drawing that one shape.
- Placed in the world. The modelling transformation, here T(1, 0.5), places the shape in the scene. The dot is now at (1, 0.5) in world coordinates, the frame every shape shares.
- The window chooses what shows. The window is the rectangle of the world that the view shows, x from −2 to 2 and y from −1 to 1. The dot is inside it; parts of shapes outside the window are clipped, as the next two lessons do.
- Normalised to 0 to 1. Measuring from the window's corner and dividing by its size gives (1 + 2) ÷ 4 = 0.75 across and (0.5 + 1) ÷ 2 = 0.75 up. Normalised coordinates no longer depend on the window's units.
- Pixels, counted downwards. The viewport is 400 by 200 pixels with y counted from the top, so x = 0.75 × 400 = 300 and y = (1 − 0.75) × 200 = 50. The dot is drawn at pixel (300, 50).
- One matrix for the view. The window, normalising and viewport stages multiply into one window-to-viewport map, x_v = 100 (x + 2) and y_v = 100 (1 − y). It sends (1, 0.5) straight to (300, 50).
UGC NET and university syllabi
Window-to-viewport mapping appears in Unit 3 of the UGC NET Computer Science and Applications syllabus, as “Viewing Pipeline, Viewing Coordinate Reference Frame, Window to View-Port Coordinate Transformation, Viewing Functions”. In GTU’s BE05000161 syllabus, windowing and the viewport transformation open the unit on viewing and clipping. Try one in exam style: map (1, 0.5) from the window (−2, −1) to (2, 1) onto a 400 × 200 viewport whose y axis points down. It lands at (300, 50).
The program maps the window onto three viewports. Its first lines check the corners and the point:
// Window-to-viewport mapping: a window of the world (y up) onto a viewport of pixels
// (y down, origin at the top left). Each axis gets one scale and one shift:
// x_v = vx + (x - wxmin) * sx sx = width / (wxmax - wxmin)
// y_v = vy + (wymax - y) * sy sy = height / (wymax - wymin)
// "stretch" uses both scales as they are; "letterbox" and "crop" use one scale for both
// axes, the smaller or the larger, and centre the window in the viewport.
function windowToViewport(win, vp, fit = 'stretch') {
let sx = vp.width / (win.xmax - win.xmin);
let sy = vp.height / (win.ymax - win.ymin);
if (fit === 'letterbox') sx = sy = Math.min(sx, sy);
if (fit === 'crop') sx = sy = Math.max(sx, sy);
const cx = (win.xmin + win.xmax) / 2; // the window's centre goes to the viewport's centre
const cy = (win.ymin + win.ymax) / 2;
const vcx = vp.x + vp.width / 2;
const vcy = vp.y + vp.height / 2;
return { sx, sy, map: ([x, y]) => [vcx + (x - cx) * sx, vcy - (y - cy) * sy] };
}
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
const point = ([x, y]) => `(${num(x)}, ${num(y)})`;
const WINDOW = { xmin: -2, xmax: 2, ymin: -1, ymax: 1 }; // 4 by 2 units: twice as wide as tall
const VIEWPORTS = [
{ x: 0, y: 0, width: 400, height: 200 },
{ x: 0, y: 0, width: 300, height: 300 },
{ x: 0, y: 0, width: 640, height: 360 },
];
const P = [1, 0.5];
console.log('window x from -2 to 2, y from -1 to 1');
const stretch = windowToViewport(WINDOW, VIEWPORTS[0]);
console.log(`viewport 400 x 200: sx = ${num(stretch.sx)}, sy = ${num(stretch.sy)} pixels per unit`);
for (const [label, p] of [["(-2, 1), the window's top left", [-2, 1]], ['(2, -1), its bottom right', [2, -1]], ['(1, 0.5), a point inside it', P]]) {
console.log(` ${label.padEnd(32)}-> ${point(stretch.map(p))}`);
}
for (const vp of VIEWPORTS.slice(1)) {
console.log('');
console.log(`viewport ${vp.width} x ${vp.height}`);
for (const fit of ['stretch', 'letterbox', 'crop']) {
const m = windowToViewport(WINDOW, vp, fit);
// The unit circle drawn through this map: its width and height on screen.
const w = 2 * m.sx;
const h = 2 * m.sy;
let what = `the unit circle is ${num(w)} x ${num(h)} pixels`;
if (fit === 'letterbox') {
const [x0, y0] = m.map([WINDOW.xmin, WINDOW.ymax]);
const [x1, y1] = m.map([WINDOW.xmax, WINDOW.ymin]);
what += `; the window fills ${num(x1 - x0)} x ${num(y1 - y0)}, bars of ${num(x0 - vp.x)} left and right, ${num(y0 - vp.y)} top and bottom`;
}
if (fit === 'crop') {
const halfW = vp.width / 2 / m.sx;
const halfH = vp.height / 2 / m.sy;
what += `; it shows x from ${num(-halfW)} to ${num(halfW)} and y from ${num(-halfH)} to ${num(halfH)}`;
}
console.log(` ${fit.padEnd(10)}sx = ${num(m.sx).padEnd(4)} sy = ${num(m.sy).padEnd(4)} ${what}`);
}
} Output
window x from -2 to 2, y from -1 to 1 viewport 400 x 200: sx = 100, sy = 100 pixels per unit (-2, 1), the window's top left -> (0, 0) (2, -1), its bottom right -> (400, 200) (1, 0.5), a point inside it -> (300, 50) viewport 300 x 300 stretch sx = 75 sy = 150 the unit circle is 150 x 300 pixels letterbox sx = 75 sy = 75 the unit circle is 150 x 150 pixels; the window fills 300 x 150, bars of 0 left and right, 75 top and bottom crop sx = 150 sy = 150 the unit circle is 300 x 300 pixels; it shows x from -1 to 1 and y from -1 to 1 viewport 640 x 360 stretch sx = 160 sy = 180 the unit circle is 320 x 360 pixels letterbox sx = 160 sy = 160 the unit circle is 320 x 320 pixels; the window fills 640 x 320, bars of 0 left and right, 20 top and bottom crop sx = 180 sy = 180 the unit circle is 360 x 360 pixels; it shows x from -1.778 to 1.778 and y from -1 to 1
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node window_viewport.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
On the 400 × 200 viewport the window’s top left corner, (−2, 1), goes to pixel (0, 0) and its bottom right, (2, −1), to (400, 200). The map turns the window upside down so that it looks the right way up on a screen; that flip is the y_max − y in the second formula, and leaving it out is a common bug in this code.
Mapping y the way you map x
Writing y_v = v_y + (y − y_min) s_y, the same form as x, sends the window’s bottom edge to the top row of pixels: (−2, −1) lands at (0, 0) and every shape is drawn upside down. In a canvas, in SVG and in WebGPU’s framebuffers y grows downwards, so measure y from the window’s top edge.
Checkpoint
Check: The window-to-viewport map
Unequal scales distort shapes: letterbox or crop instead
Put the same window, 4 units by 2, onto a square viewport of 300 by 300 and the two scales part company: 75 pixels a unit across, 150 down. The unit circle comes out 150 pixels wide and 300 tall. Distortion appears whenever the window’s aspect ratio: Width divided by height. A window and a viewport with different aspect ratios cannot be matched with one scale without bars or a crop., its width over its height, differs from the viewport’s: here 2 against 1. Two fits keep shapes true, and both use one scale for both axes:
Stretch, letterbox and crop on a 300 × 300 viewport
Text description of the diagram
Three rows, each a square viewport of 300 by 300 pixels drawn small on the left and a description on the right. Each viewport shows the window, x from −2 to 2 and y from −1 to 1, as a solid rectangle, and the unit circle dashed.
Stretch: the window fills the whole square. Its scales are 75 pixels a unit across and 150 down, so the circle becomes an ellipse 150 pixels wide and 300 tall, and every shape is distorted.
Letterbox: both axes use the smaller scale, 75. The window is a band 300 wide and 150 tall across the middle, with grey bars of 75 pixels above and below it, and the circle is a true circle 150 pixels across.
Crop: both axes use the larger scale, 150. The circle is a true circle 300 pixels across that fills the square, and the window's left and right ends are cut off: only x from −1 to 1 shows.
letterboxing: Fitting a picture into a viewport of another shape with one scale, the smaller of the two, so that all of it shows and the spare pixels become bars. takes the smaller scale, 75, so that the whole window fits and the spare pixels become bars: the window fills a band of 300 by 150 with 75 pixels above and below it. Cropping takes the larger scale, 150, so that the viewport is filled and the window’s ends are cut off: only x from −1 to 1 shows. The lower lines of the program give the numbers for the square viewport and for a 640 × 360 one, whose 16 : 9 shape is closer to the window’s 2 : 1:
| Fit | 300 × 300 viewport | 640 × 360 viewport |
|---|---|---|
| Stretch | circle 150 × 300 | circle 320 × 360 |
| Letterbox | circle 150 × 150, bars of 75 | circle 320 × 320, bars of 20 |
| Crop | circle 300 × 300, x from −1 to 1 | circle 360 × 360, x from −1.778 to 1.778 |
The choice is about what may be lost. Letterboxing keeps every part of the window and spends pixels on bars; cropping
spends none and loses the window’s edges. Canvases on a web page face the same choice one level up: the HTML standard
fits a canvas’s bitmap into its CSS box with the object-fit property, whose initial value, fill, stretches it,
while contain keeps the aspect ratio inside the box, a letterbox, and cover fills the box, a crop.
Letterbox charts, crop backgrounds
Letterbox a view whose every point carries information, such as a chart, a map or a game board, so that nothing falls off the edge. Crop a decorative background, where filling the screen matters more than its edges.
Checkpoint
Check: Stretch, letterbox and crop
A frame’s matrix holds its axes and origin as columns
A car stands on a 30° slope, its wheel has turned, and a point on the wheel’s rim has to be drawn in the world. Each object has its own coordinate frame: An origin and a pair of axes, given in a parent frame's coordinates. Its matrix has the axes and the origin as columns and maps the frame's coordinates to the parent's., an origin and two axes measured in its parent’s frame, and the matrix of a frame has exactly those as its columns:
Here u is the frame’s x axis, v its y axis and o its origin, all in the parent’s coordinates. F times a point’s local coordinates (a, b, 1) is o + a u + b v: start at the frame’s origin, walk a along its x axis and b along its y axis. The car’s frame has u = (0.866, 0.5), v = (−0.5, 0.866) and o = (10, 2). The wheel’s frame sits inside the car’s, so a rim point goes through the wheel’s matrix first and the car’s after it, and coming back takes the inverses in the opposite order:
The drawing shows the whole scene; its 7 steps are listed below.
Every step as text
- A point on the rim. The wheel's own frame has its origin at the hub, so a point on a rim of radius 0.4 is simply (0.4, 0). Nothing about the car or the world is in these coordinates.
- Into the car's frame. The wheel's frame matrix holds its axes and its hub as columns, measured in the car's frame. The hub is at (1.5, −0.5) and the wheel has turned by 90°, so the rim point becomes (1.5, −0.1).
- Into the world. The car's frame matrix does the same one level up. The car stands at (10, 2) on a 30° slope, so the point lands at (11.349, 2.663) in the world.
- Start from the world. Going back starts from the world point. Each frame matrix is a turn and a move, so its inverse undoes the move and turns back by the transpose.
- Back into the car. The car's inverse comes first, because the car's matrix was applied last. It gives (1.5, −0.1) in the car's frame again.
- Back on the rim. The wheel's inverse returns the point to (0.4, 0) in the wheel's frame, within 1e-12 of where it started.
- The wrong order. Multiplying the matrices the other way round, wheel times car, treats the car's frame as if it sat inside the wheel's. It puts the rim point at (−0.7, 9.846), nowhere near the car.
// Nested coordinate frames: a point on a wheel, a wheel on a car, a car in the world.
// A frame's matrix has the frame's x axis, y axis and origin, written in its parent's
// coordinates, as its three columns. It maps the frame's coordinates to the parent's;
// its inverse maps the parent's back.
const frame = (origin, degrees) => {
const r = (degrees * Math.PI) / 180;
const xAxis = [Math.cos(r), Math.sin(r)];
const yAxis = [-Math.sin(r), Math.cos(r)];
return [[xAxis[0], yAxis[0], origin[0]], [xAxis[1], yAxis[1], origin[1]], [0, 0, 1]];
};
function multiply(a, b) {
const out = [[0, 0, 0], [0, 0, 0], [0, 0, 0]];
for (let i = 0; i < 3; i++) for (let j = 0; j < 3; j++) for (let k = 0; k < 3; k++) out[i][j] += a[i][k] * b[k][j];
return out;
}
// A frame is a turn and a move, so its inverse turns back by the transpose: [R^T, -R^T t].
function invertFrame(m) {
const [a, c, tx] = m[0];
const [b, d, ty] = m[1];
return [[a, b, -(a * tx + b * ty)], [c, d, -(c * tx + d * ty)], [0, 0, 1]];
}
const apply = (m, [x, y]) => [m[0][0] * x + m[0][1] * y + m[0][2], m[1][0] * x + m[1][1] * y + m[1][2]];
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
const point = ([x, y]) => `(${num(x)}, ${num(y)})`;
const car = frame([10, 2], 30); // the car sits at (10, 2) in the world, on a 30 degree slope
const wheel = frame([1.5, -0.5], 90); // the wheel's hub, in the car's frame; the wheel has turned 90 degrees
const rim = [0.4, 0]; // a point on the rim, in the wheel's own frame
console.log(`car frame in the world: x axis ${point([car[0][0], car[1][0]])}, y axis ${point([car[0][1], car[1][1]])}, origin ${point([car[0][2], car[1][2]])}`);
const inCar = apply(wheel, rim);
const inWorld = apply(car, inCar);
console.log(`the rim point ${point(rim)} in the wheel's frame`);
console.log(` is ${point(inCar)} in the car's frame (wheel matrix)`);
console.log(` is ${point(inWorld)} in the world (car matrix after it)`);
const wheelInWorld = multiply(car, wheel);
console.log(`one matrix, car times wheel, sends it straight to ${point(apply(wheelInWorld, rim))}`);
console.log(`its columns are the wheel's axes and hub in the world: ${point([wheelInWorld[0][0], wheelInWorld[1][0]])}, ${point([wheelInWorld[0][1], wheelInWorld[1][1]])}, ${point([wheelInWorld[0][2], wheelInWorld[1][2]])}`);
const back = apply(invertFrame(wheel), apply(invertFrame(car), inWorld));
console.log('');
console.log(`back again: world -> car -> wheel gives ${point(back)}`);
console.log(`round trip within 1e-12: ${Math.hypot(back[0] - rim[0], back[1] - rim[1]) < 1e-12 ? 'yes' : 'no'}`);
const wrong = apply(multiply(wheel, car), rim);
console.log(`the wrong order, wheel times car, puts the point at ${point(wrong)}`); Output
car frame in the world: x axis (0.866, 0.5), y axis (-0.5, 0.866), origin (10, 2) the rim point (0.4, 0) in the wheel's frame is (1.5, -0.1) in the car's frame (wheel matrix) is (11.349, 2.663) in the world (car matrix after it) one matrix, car times wheel, sends it straight to (11.349, 2.663) its columns are the wheel's axes and hub in the world: (-0.5, 0.866), (-0.866, -0.5), (11.549, 2.317) back again: world -> car -> wheel gives (0.4, 0) round trip within 1e-12: yes the wrong order, wheel times car, puts the point at (-0.7, 9.846)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node frames.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
The rim point (0.4, 0) is (1.5, −0.1) in the car’s frame and (11.349, 2.663) in the world, and one matrix, car times wheel, makes both steps at once; its columns are the wheel’s axes and hub written in the world. Each frame is a turn and a move, a rigid transformation: A rotation followed by a translation. It keeps every length and angle, and its inverse is the transposed rotation with a negated, rotated translation. More in “Matrices and homogeneous coordinates”, so its inverse subtracts the origin and turns back by the transpose, and the round trip returns (0.4, 0) within 1e-12. Multiplied the other way round, wheel times car, the same matrices put the point at (−0.7, 9.846), nowhere near the car.
Key idea
A frame’s matrix has the frame’s axes and origin, in its parent’s coordinates, as its columns. Parent times child takes a point out towards the world, the child’s matrix acting first; the inverses, in the opposite order, bring it back.
Checkpoint
Check: Nested coordinate frames
WebGPU’s viewport maps normalized device coordinates to pixels
A GPU ends its pipeline with the same map and a fixed window. WebGPU divides each position the vertex shader returns by
its w, giving normalized device coordinates (NDC): The coordinates a GPU reaches after dividing positions by w. In WebGPU, x and y run from −1 to 1 with y up and depth from 0 to 1; the viewport maps them to pixels., NDC, in which x and y run
from −1 to 1 with y up and depth runs from 0 to 1. The viewport set with
setViewport(x, y, width, height, minDepth, maxDepth) then maps them to framebuffer pixels, whose origin is the top
left corner with y down. The specification writes the map as:
In words: shift NDC from −1…1 to 0…2, halve it, scale it to the viewport’s size, and flip y. Depth is mapped the same way, from 0…1 to minDepth…maxDepth. It is the window-to-viewport map of the first section, with the window fixed at −1 to 1 on both axes:
Normalized device coordinates mapped to framebuffer pixels
Text description of the diagram
Two panels joined by an arrow, under the call setViewport(0, 0, 800, 600, 0, 1).
The left panel, "NDC, y up", shows a square whose top left corner is (−1, 1) and bottom right corner is (1, −1). A dark dot marks its centre, (0, 0), and a blue dot the point (0.5, 0.5), up and to the right of the centre.
The right panel, "Pixels, y down", shows the framebuffer, 800 by 600 pixels, with (0, 0) at the top left and (800, 600) at the bottom right. The centre dot is at (400, 300) and the blue dot at (600, 150): three quarters of the way across and a quarter of the way down. The square of NDC has become a rectangle, 400 pixels to a unit across and 300 down.
// WebGPU's viewport transform (WebGPU specification, section 23.2.5): normalized device
// coordinates (x and y from -1 to 1 with y up, z from 0 to 1) go to framebuffer pixels
// (origin at the top left, y down) and to a depth in [minDepth, maxDepth]:
// x_fb = vp.x + 0.5 * (n.x + 1) * vp.width
// y_fb = vp.y + 0.5 * (-n.y + 1) * vp.height
// depth = vp.minDepth + n.z * (vp.maxDepth - vp.minDepth)
function framebufferCoords(n, vp) {
return [vp.x + 0.5 * (n[0] + 1) * vp.width, vp.y + 0.5 * (-n[1] + 1) * vp.height, vp.minDepth + n[2] * (vp.maxDepth - vp.minDepth)];
}
const num = (v) => {
const r = Number(v.toFixed(4));
return Object.is(r, -0) ? '0' : String(r);
};
const show = (p) => `(${p.map(num).join(', ')})`;
const full = { x: 0, y: 0, width: 800, height: 600, minDepth: 0, maxDepth: 1 };
const corner = { x: 400, y: 0, width: 400, height: 300, minDepth: 0, maxDepth: 1 };
const nearHalf = { x: 0, y: 0, width: 800, height: 600, minDepth: 0.5, maxDepth: 1 };
const points = [
['(-1, 1), top left', [-1, 1, 0]],
['(1, -1), bottom right', [1, -1, 0]],
['(0, 0), the centre', [0, 0, 0]],
['(0.5, 0.5)', [0.5, 0.5, 0]],
];
console.log('setViewport(0, 0, 800, 600, 0, 1): the whole 800 x 600 target');
for (const [label, n] of points) console.log(` NDC ${label.padEnd(22)}-> pixel ${show(framebufferCoords(n, full).slice(0, 2))}`);
console.log(` one unit of NDC is ${num(full.width / 2)} pixels across and ${num(full.height / 2)} pixels down`);
console.log('');
console.log('setViewport(400, 0, 400, 300, 0, 1): the top right quarter');
for (const [label, n] of points.slice(0, 3)) console.log(` NDC ${label.padEnd(22)}-> pixel ${show(framebufferCoords(n, corner).slice(0, 2))}`);
console.log('');
console.log('depth: NDC z = 0.25 is stored as');
console.log(` ${num(framebufferCoords([0, 0, 0.25], full)[2])} with minDepth 0 and maxDepth 1`);
console.log(` ${num(framebufferCoords([0, 0, 0.25], nearHalf)[2])} with minDepth 0.5 and maxDepth 1`);
// The pixel in the top left corner covers x and y from 0 to 1; its centre is (0.5, 0.5).
// Inverting the map tells where that centre lies in NDC.
const ndcOf = ([x, y], vp) => [((x - vp.x) / vp.width) * 2 - 1, 1 - ((y - vp.y) / vp.height) * 2];
console.log('');
console.log(`the centre of the top left pixel, (0.5, 0.5), is NDC ${show(ndcOf([0.5, 0.5], full))}`); Output
setViewport(0, 0, 800, 600, 0, 1): the whole 800 x 600 target NDC (-1, 1), top left -> pixel (0, 0) NDC (1, -1), bottom right -> pixel (800, 600) NDC (0, 0), the centre -> pixel (400, 300) NDC (0.5, 0.5) -> pixel (600, 150) one unit of NDC is 400 pixels across and 300 pixels down setViewport(400, 0, 400, 300, 0, 1): the top right quarter NDC (-1, 1), top left -> pixel (400, 0) NDC (1, -1), bottom right -> pixel (800, 300) NDC (0, 0), the centre -> pixel (600, 150) depth: NDC z = 0.25 is stored as 0.25 with minDepth 0 and maxDepth 1 0.625 with minDepth 0.5 and maxDepth 1 the centre of the top left pixel, (0.5, 0.5), is NDC (-0.9988, 0.9983)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node ndc_viewport.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
On the full 800 × 600 target NDC (−1, 1) is pixel (0, 0), the centre is (400, 300) and (0.5, 0.5) is (600, 150).
Setting the viewport to the top right quarter, setViewport(400, 0, 400, 300, 0, 1), sends the same centre to
(600, 150), which is how one canvas shows several views side by side. With minDepth 0.5 a depth of 0.25 is stored as
0.625. Pixels are sampled at their centres, and the centre of the top left pixel, (0.5, 0.5) in framebuffer
coordinates, is NDC (−0.9988, 0.9983). One unit of NDC is 400 pixels across but 300 down, so a square in NDC is drawn as
a rectangle: the stretch of the previous section, which the camera’s projection matrix will undo with the viewport’s
aspect ratio in the cameras module.
Checkpoint
Check: WebGPU's viewport and NDC
Size the canvas bitmap in device pixels, or it blurs
A canvas has two sizes. Its width and height attributes set its bitmap, 300 by 150 when they are not given, and CSS
sets the box it takes on the page, in CSS pixels; the HTML standard’s own example draws a 200 × 200 bitmap in a
100 × 100 box. A screen’s device pixel ratio: The size of a CSS pixel divided by the size of a device pixel, window.devicePixelRatio. A canvas needs its CSS size times this ratio in bitmap pixels to look sharp., window.devicePixelRatio, is the
size of a CSS pixel divided by the size of a device pixel. On a phone whose ratio is 2 or 3, a bitmap sized in CSS
pixels is stretched across several device pixels for each of its own:
The same canvas box with a small and a full-size bitmap
Text description of the diagram
Two panels, each a grid of 8 by 6 small squares, where each square is one device pixel of a screen whose device pixel ratio is 2.
Left, "Bitmap 400 × 200, 2 × 2 device pixels each": the bitmap has one pixel per CSS pixel, so each of its pixels is stretched over a block of 2 by 2 device pixels. A line one bitmap pixel thick fills two full rows of device pixels. The light rows above and below it show what a smoothing scaler adds when it blends the stretched pixels into their neighbours; the browser chooses the scaling algorithm, so the line is either soft like this or blocky, and twice as thick either way.
Right, "Bitmap 800 × 400, 1 device pixel each": the bitmap has one pixel per device pixel, and the same line fills a single row of device pixels with hard edges.
The fix is to give the bitmap one pixel per device pixel, canvas.width = Math.round(cssWidth * devicePixelRatio),
and to keep drawing in CSS pixels with ctx.scale(devicePixelRatio, devicePixelRatio). How the browser fills in a
stretched bitmap is its own choice, since CSS’s image-rendering starts as auto, but a stretched line is twice as
thick at ratio 2 whatever it chooses. The program works out what the fix costs, and what a click means:
// A canvas has two sizes: its box on the page, in CSS pixels, and its bitmap, in canvas
// pixels (the width and height attributes). devicePixelRatio is how many device pixels
// one CSS pixel covers. For a sharp drawing the bitmap needs one pixel per device pixel:
// canvas.width = Math.round(cssWidth * devicePixelRatio)
// With the default colour type, unorm8, each of a pixel's four components takes 8 bits:
// 4 bytes a pixel. The program works the numbers out; it runs no browser.
const num = (v) => {
const r = Number(v.toFixed(3));
return Object.is(r, -0) ? '0' : String(r);
};
const css = { width: 400, height: 200 }; // the canvas's box, set by CSS
console.log(`a canvas box of ${css.width} x ${css.height} CSS pixels`);
console.log('ratio bitmap for a sharp drawing memory (4 bytes a pixel) device pixels per bitmap pixel if left at 400 x 200');
for (const dpr of [1, 1.25, 1.5, 2, 3]) {
const w = Math.round(css.width * dpr);
const h = Math.round(css.height * dpr);
const bytes = w * h * 4;
console.log(`${String(dpr).padEnd(7)}${`${w} x ${h}`.padEnd(30)}${`${num(bytes / 1e6)} MB`.padEnd(26)}${num(dpr * dpr)}`);
}
// A box whose CSS size times the ratio is not a whole number of pixels.
const odd = 333;
const exact = odd * 1.5;
const rounded = Math.round(exact);
console.log('');
console.log(`a box ${odd} CSS pixels wide at ratio 1.5 covers ${exact} device pixels; the bitmap gets ${rounded}`);
console.log(`so ${rounded} bitmap pixels are squeezed into ${exact} device pixels: ${num(rounded / exact)} per device pixel, a slight resampling`);
// A click arrives in CSS pixels, relative to the box. The drawing uses bitmap pixels,
// and the scene uses world units: window x from -2 to 2, y from -1 to 1 on the whole bitmap.
const dpr = 2;
const bitmap = { width: Math.round(css.width * dpr), height: Math.round(css.height * dpr) };
const win = { xmin: -2, xmax: 2, ymin: -1, ymax: 1 };
const click = [300, 50]; // offsetX and offsetY of a pointer event, in CSS pixels
const inBitmap = [click[0] * (bitmap.width / css.width), click[1] * (bitmap.height / css.height)];
const inWorld = [win.xmin + inBitmap[0] / (bitmap.width / (win.xmax - win.xmin)), win.ymax - inBitmap[1] / (bitmap.height / (win.ymax - win.ymin))];
console.log('');
console.log(`at ratio ${dpr} the bitmap is ${bitmap.width} x ${bitmap.height}`);
console.log(`a click at CSS (${click.join(', ')}) is bitmap pixel (${inBitmap.map(num).join(', ')}) and world point (${inWorld.map(num).join(', ')})`);
const wrong = [win.xmin + click[0] / (bitmap.width / (win.xmax - win.xmin)), win.ymax - click[1] / (bitmap.height / (win.ymax - win.ymin))];
console.log(`skipping the CSS-to-bitmap step reads it as world point (${wrong.map(num).join(', ')})`); Output
a canvas box of 400 x 200 CSS pixels ratio bitmap for a sharp drawing memory (4 bytes a pixel) device pixels per bitmap pixel if left at 400 x 200 1 400 x 200 0.32 MB 1 1.25 500 x 250 0.5 MB 1.563 1.5 600 x 300 0.72 MB 2.25 2 800 x 400 1.28 MB 4 3 1200 x 600 2.88 MB 9 a box 333 CSS pixels wide at ratio 1.5 covers 499.5 device pixels; the bitmap gets 500 so 500 bitmap pixels are squeezed into 499.5 device pixels: 1.001 per device pixel, a slight resampling at ratio 2 the bitmap is 800 x 400 a click at CSS (300, 50) is bitmap pixel (600, 100) and world point (1, 0.5) skipping the CSS-to-bitmap step reads it as world point (-0.5, 0.75)
Recorded with Node.js 24.21.0 on macOS 26 arm64. To run it yourself: mise exec node@24.21.0 -- node canvas_size.mjs
Runs on this device, in your browser. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs.
Your run, in this browser
A 400 × 200 box needs an 800 × 400 bitmap at ratio 2 and 1200 × 600 at ratio 3, which takes 2.88 MB at 4 bytes a pixel, the canvas’s default unorm8 colour type: nine times the memory of ratio 1. Left at 400 × 200 on a ratio-3 screen, each bitmap pixel covers 9 device pixels. Some boxes cannot match exactly: 333 CSS pixels at ratio 1.5 cover 499.5 device pixels, and the bitmap has to round to 500. Clicks travel the other way. They arrive in CSS pixels relative to the box, so multiply by the bitmap’s width over the box’s before applying the inverse of the window-to-viewport map: a click at (300, 50) is bitmap pixel (600, 100) and world point (1, 0.5), while skipping that step reads it as (−0.5, 0.75).
The ratio can change while the page is open
CSSOM View measures a CSS pixel at the current page zoom, so zooming the page changes devicePixelRatio. Size the bitmap again when the ratio or the box changes, not only when the page loads.
Checkpoint
Check: Canvas size and devicePixelRatio
Practise: map, fit and change frames
Two exercises turn the lesson into code, and their sample tests run here in the page. The first maps a window onto a viewport with every fit, held to the lesson’s three viewports; the second moves points between nested frames and back, held to the car and wheel and to 200 seeded chains of frames:
Code lab · 2 steps
Build the view of a 2D scene
Map a world window onto a viewport with the right flip and fit, then move points between nested coordinate frames and back.
- Map a world window onto a viewport, with or without distortion To do
- Move points between nested coordinate frames To do
Exercise · Easy · JavaScript
Map a world window onto a viewport, with or without distortion
Finish windowToViewport(win, vp, fit) in viewport.mjs. It returns the 3 × 3 matrix, as three rows, that maps the world window { xmin, xmax, ymin, ymax }, where y points up, to the viewport { x, y, width, height } in pixels, where the origin is the top left corner and y points down.
'stretch'gives each axis its own scale;'letterbox'gives both axes the smaller scale and'crop'the larger, and both of those centre the window in the viewport.- Throw a
RangeErrorwhen the window or the viewport has no width or no height.
The starter stretches every time and forgets that y points down on the screen, so the picture comes out upside down. The sample tests check the lesson's window on its three viewports with every fit, a viewport that does not start at the origin, and the sizes that must be refused.
Starter code · viewport.mjs
/**
* windowToViewport(win, vp, fit) returns the 3 x 3 matrix, as three rows, that maps the window
* win = { xmin, xmax, ymin, ymax } of the world, where y points up, to the viewport
* vp = { x, y, width, height } in pixels, where the origin is the top left corner and y points down.
* fit 'stretch': each axis gets its own scale
* fit 'letterbox': both axes get the smaller scale, and the window is centred in the viewport
* fit 'crop': both axes get the larger scale, and the window is centred in the viewport
* Throw a RangeError when the window or the viewport has no width or no height.
* This first version stretches every time and forgets that y points down on the screen.
*/
export function windowToViewport(win, vp, fit = 'stretch') {
const sx = vp.width / (win.xmax - win.xmin);
const sy = vp.height / (win.ymax - win.ymin);
return [[sx, 0, vp.x - win.xmin * sx], [0, sy, vp.y - win.ymin * sy], [0, 0, 1]];
} The sample tests · viewport.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { windowToViewport } from './viewport.mjs';
const apply = (m, [x, y]) => [m[0][0] * x + m[0][1] * y + m[0][2], m[1][0] * x + m[1][1] * y + m[1][2]];
const round = (v) => {
const r = Math.round(v * 1000) / 1000;
return Object.is(r, -0) ? 0 : r;
};
const at = (m, p) => apply(m, p).map(round);
const WINDOW = { xmin: -2, xmax: 2, ymin: -1, ymax: 1 };
test('the lesson window on a 400 x 200 viewport, y flipped', () => {
const m = windowToViewport(WINDOW, { x: 0, y: 0, width: 400, height: 200 });
assert.deepEqual(at(m, [-2, 1]), [0, 0], "the window's top left goes to the viewport's top left");
assert.deepEqual(at(m, [2, -1]), [400, 200], 'its bottom right goes to the bottom right');
assert.deepEqual(at(m, [1, 0.5]), [300, 50], 'the point (1, 0.5)');
assert.deepEqual(m[2], [0, 0, 1], 'the bottom row stays (0, 0, 1)');
});
test('a viewport that does not start at the origin', () => {
const m = windowToViewport(WINDOW, { x: 50, y: 20, width: 400, height: 200 });
assert.deepEqual(at(m, [-2, 1]), [50, 20], 'top left');
assert.deepEqual(at(m, [0, 0]), [250, 120], 'the centre');
});
test('stretch, letterbox and crop on a square viewport', () => {
const vp = { x: 0, y: 0, width: 300, height: 300 };
const s = windowToViewport(WINDOW, vp, 'stretch');
assert.deepEqual(at(s, [1, 0]).concat(at(s, [0, 1])), [225, 150, 150, 0], 'stretch: 75 pixels a unit across, 150 down');
const l = windowToViewport(WINDOW, vp, 'letterbox');
assert.deepEqual(at(l, [-2, 1]), [0, 75], 'letterbox: a bar of 75 above the window');
assert.deepEqual(at(l, [2, -1]), [300, 225], 'letterbox: and 75 below it');
assert.deepEqual(at(l, [0, 0]), [150, 150], 'letterbox: the centres meet');
const c = windowToViewport(WINDOW, vp, 'crop');
assert.deepEqual(at(c, [-1, 1]), [0, 0], 'crop: x = -1 is the left edge');
assert.deepEqual(at(c, [1, -1]), [300, 300], 'crop: x = 1 is the right edge');
});
test('a 16:9 viewport', () => {
const vp = { x: 0, y: 0, width: 640, height: 360 };
assert.deepEqual(at(windowToViewport(WINDOW, vp, 'letterbox'), [-2, 1]), [0, 20], 'letterbox: bars of 20');
const c = windowToViewport(WINDOW, vp, 'crop');
assert.deepEqual(at(c, [0, 1]), [320, 0], 'crop: y = 1 is the top edge');
assert.equal(round(c[0][0]), 180, 'crop: 180 pixels a unit');
});
test('a window or viewport with no width or height is refused', () => {
assert.throws(() => windowToViewport({ xmin: 1, xmax: 1, ymin: 0, ymax: 2 }, { x: 0, y: 0, width: 100, height: 100 }), 'a window of width 0');
assert.throws(() => windowToViewport(WINDOW, { x: 0, y: 0, width: 0, height: 100 }), 'a viewport of width 0');
assert.throws(() => windowToViewport(WINDOW, { x: 0, y: 0, width: 100, height: 0 }, 'letterbox'), 'a viewport of height 0');
}); A hint
Work from the centres: the window's centre (cx, cy) must land on the viewport's centre (vcx, vcy). Then x_v = vcx + (x − cx)·sx and, because screen y grows downwards, y_v = vcy − (y − cy)·sy. For letterbox and crop, set both scales to the smaller or the larger of the two before you build the rows.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Exercise · Medium · JavaScript
Move points between nested coordinate frames
Finish toParent(frames, p) and toInnermost(frames, q) in frames.mjs. A frame is { origin, degrees }: where its origin is and how far its x axis is turned, both in its parent's coordinates, and frameMatrix(frame) already returns its matrix. frames lists a chain from the outermost frame inwards, such as [car, wheel].
toParenttakes a point given in the innermost frame and returns it in the outermost frame's parent, the world.toInnermostdoes the opposite. Every frame is a turn and a move, so it can be undone without a general inverse.
The starter applies the frames in the wrong order and undoes them with a plain transpose, which loses the translation. The sample tests check the lesson's car and wheel, single frames, and 200 seeded chains of one to four frames that must go out and come back within 1e-9.
Starter code · frames.mjs
/**
* A frame is { origin: [x, y], degrees }: where its origin is and how far its x axis is turned,
* both measured in its parent's coordinates.
* frameMatrix(frame) the 3 x 3 matrix whose columns are the frame's x axis, y axis and origin
* toParent(frames, p) p is given in the innermost frame of `frames`, which lists the frames from the
* outermost inwards; return it in the outermost frame's parent (the world)
* toInnermost(frames, q) the inverse: a point of the world, in the innermost frame
* frameMatrix already works. toParent applies the frames in the wrong order, and toInnermost
* transposes the whole matrix, which loses the translation.
*/
export function frameMatrix({ origin, degrees }) {
const r = (degrees * Math.PI) / 180;
const c = Math.cos(r);
const s = Math.sin(r);
return [[c, -s, origin[0]], [s, c, origin[1]], [0, 0, 1]];
}
const apply = (m, [x, y]) => [m[0][0] * x + m[0][1] * y + m[0][2], m[1][0] * x + m[1][1] * y + m[1][2]];
const transpose = (m) => m[0].map((_, j) => m.map((row) => row[j]));
export function toParent(frames, p) {
let q = p;
for (const f of frames) q = apply(frameMatrix(f), q);
return q;
}
export function toInnermost(frames, q) {
let p = q;
for (let i = frames.length - 1; i >= 0; i--) p = apply(transpose(frameMatrix(frames[i])), p);
return p;
} The sample tests · frames.test.mjs
import { test, assert } from 'mysmartcopilot:test';
import { frameMatrix, toParent, toInnermost } from './frames.mjs';
const close = (a, b, eps = 1e-9) => Math.abs(a[0] - b[0]) < eps && Math.abs(a[1] - b[1]) < eps;
const show = (p) => `(${p.map((v) => v.toFixed(4)).join(', ')})`;
const CAR = { origin: [10, 2], degrees: 30 };
const WHEEL = { origin: [1.5, -0.5], degrees: 90 };
const c30 = Math.cos(Math.PI / 6);
test("a frame's matrix holds its axes and its origin as columns", () => {
const m = frameMatrix(CAR);
assert.ok(close([m[0][0], m[1][0]], [c30, 0.5]), 'the x axis (0.866, 0.5)');
assert.ok(close([m[0][1], m[1][1]], [-0.5, c30]), 'the y axis (-0.5, 0.866)');
assert.ok(close([m[0][2], m[1][2]], [10, 2]), 'the origin (10, 2)');
});
test('the rim point of the lesson, out to the world and back', () => {
const world = toParent([CAR, WHEEL], [0.4, 0]);
const expected = [10 + 1.5 * c30 + 0.1 * 0.5, 2 + 1.5 * 0.5 - 0.1 * c30];
assert.ok(close(world, expected), `world point ${show(world)}, expected ${show(expected)}`);
assert.ok(close(toParent([WHEEL], [0.4, 0]), [1.5, -0.1]), 'in the car frame: (1.5, -0.1)');
const back = toInnermost([CAR, WHEEL], world);
assert.ok(close(back, [0.4, 0]), `back in the wheel frame: ${show(back)}`);
});
test('one frame: the origin and the axes', () => {
assert.ok(close(toParent([CAR], [0, 0]), [10, 2]), "the frame's own origin is its origin in the parent");
assert.ok(close(toInnermost([CAR], [10, 2]), [0, 0]), 'and back');
assert.ok(close(toInnermost([{ origin: [3, 4], degrees: 0 }], [5, 5]), [2, 1]), 'a frame that is only moved');
});
test('200 seeded chains of frames go out and back', () => {
let seed = 5; // mulberry32, so every run tests the same chains
const random = () => {
seed = (seed + 0x6d2b79f5) | 0;
let t = Math.imul(seed ^ (seed >>> 15), 1 | seed);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
for (let n = 0; n < 200; n++) {
const frames = Array.from({ length: 1 + Math.floor(random() * 4) }, () => ({ origin: [random() * 40 - 20, random() * 40 - 20], degrees: random() * 360 - 180 }));
const p = [random() * 10 - 5, random() * 10 - 5];
const q = toParent(frames, p);
// Applying the frames one at a time, innermost first, must agree with toParent.
let r = p;
for (let i = frames.length - 1; i >= 0; i--) {
const m = frameMatrix(frames[i]);
r = [m[0][0] * r[0] + m[0][1] * r[1] + m[0][2], m[1][0] * r[0] + m[1][1] * r[1] + m[1][2]];
}
assert.ok(close(q, r), `chain ${n + 1}: innermost frame first`);
assert.ok(close(toInnermost(frames, q), p), `chain ${n + 1}: out and back`);
}
}); A hint
A point given in the wheel's frame meets the wheel's matrix first and the car's after it, so apply the frames from the last one in the list back to the first. To undo one frame, subtract its origin first, then turn back by the transpose of its 2 × 2 part; undo them from the first frame in the list to the last.
Results of the sample tests
| Test | Result | Details |
|---|
What your code printed
The sample tests run on this device, in your browser (QuickJS): nothing is sent to mysmartcopilot.com. The first run downloads JavaScript (about 0.6 MB), which is kept for the next runs. A check in your browser is feedback for you, not proof that the code is right for every input.
Finish with the self-check on windows, fits, frames, NDC and canvas sizes. Its first questions come from whichever of them your answers show you know least, and a missed one is asked again:
Practice that adapts to you
Practise this lesson
18 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.
Interview questions
Warm-up (fresher to 2-5 years): What does the window-to-viewport transformation do, and what is its formula?
It maps a rectangle of the world, the window, onto a rectangle of the screen, the viewport, with one scale and one shift per axis: x_v = v_x + (x − x_min) s_x, where s_x is the viewport’s width divided by the window’s, and the same for y, measured down from the window’s top edge when screen rows are counted downwards. For a window from (−2, −1) to (2, 1) on a 400 × 200 viewport both scales are 100 pixels a unit, and (1, 0.5) lands at (300, 50). When the two scales differ, shapes are stretched; using the smaller scale for both axes letterboxes the picture and the larger crops it. As a matrix the map is a scale and a translation, and a GPU’s viewport transform is the same map with the window fixed at −1 to 1.
Two questions of this module build on it, one about a canvas on a phone and one about a map view that resizes. Each row leads to the question’s first move and the follow-ups to expect:
Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.
-
Fresher to mid
A chart canvas, 400 × 200 CSS pixels, was blurry on phones. A first fix set canvas.width to 400 times devicePixelRatio at page load and multiplied every tap's offsetX by devicePixelRatio. Now, after a user zooms the page, the chart blurs again and taps select the wrong bar. Explain both and fix them.
-
Mid to senior
A map view shows a window of 4 by 2 km in a resizable panel. When the panel is made square, circles turn into ellipses. The product team wants no distortion and no empty bars, and the place under the pointer should stay put while the user zooms. Design the mapping.
Key takeaways
- The window-to-viewport map is one scale and one shift per axis, with y measured from the window’s top edge because screens count rows downwards: (1, 0.5) lands at (300, 50) on the 400 × 200 viewport.
- When the window and the viewport differ in shape, separate scales distort it; one scale for both axes keeps shapes true, the smaller with bars (letterbox) and the larger with cut-off edges (crop).
- A frame’s matrix has the frame’s axes and origin, in its parent’s coordinates, as columns; parent times child takes a point out to the world, and the inverses in the opposite order bring it back.
- WebGPU’s viewport maps NDC, from −1 to 1 with y up, to framebuffer pixels with y down, and depth from 0…1 into minDepth…maxDepth: the same map with a fixed window.
- A canvas bitmap needs one pixel per device pixel, its CSS size times devicePixelRatio, and clicks must be scaled from CSS pixels to bitmap pixels before the inverse map: a 400 × 200 box needs an 800 × 400 bitmap at ratio 2.
Terms in this lesson
- aspect ratio
- Width divided by height. A window and a viewport with different aspect ratios cannot be matched with one scale without bars or a crop.
- coordinate frame
- An origin and a pair of axes, given in a parent frame's coordinates. Its matrix has the axes and the origin as columns and maps the frame's coordinates to the parent's.
- device pixel ratio
- The size of a CSS pixel divided by the size of a device pixel, window.devicePixelRatio. A canvas needs its CSS size times this ratio in bitmap pixels to look sharp.
- letterboxing
- Fitting a picture into a viewport of another shape with one scale, the smaller of the two, so that all of it shows and the spare pixels become bars.
- normalized device coordinates (NDC)
- The coordinates a GPU reaches after dividing positions by w. In WebGPU, x and y run from −1 to 1 with y up and depth from 0 to 1; the viewport maps them to pixels.
- viewing pipeline
- The stages that take a point from its own shape's coordinates to a pixel: modelling coordinates, world coordinates, the window, normalised coordinates and the viewport.
- viewport
- The rectangle of the screen or framebuffer, in pixels, that a view is drawn into. The window-to-viewport map takes the window onto it.
- window
- The rectangle of the world, in world units, that a view shows. Points inside it are mapped to the viewport; the parts of shapes outside it are clipped.
References
- WebGPU: 3.3 Coordinate Systems (World Wide Web Consortium (W3C))
- WebGPU: 23.2.5 Rasterization (the viewport transform) and setViewport() (World Wide Web Consortium (W3C))
- HTML Living Standard: the canvas element (width, height and the bitmap) (WHATWG)
- CSSOM View Module: the devicePixelRatio attribute (World Wide Web Consortium (W3C))
- CSS Images Module Level 3: object-fit and image-rendering (World Wide Web Consortium (W3C))
- Scalable Vector Graphics (SVG) 2: coordinate systems (World Wide Web Consortium (W3C))
- UGC NET syllabus: Computer Science and Applications (code 87) (University Grants Commission (UGC NET Bureau))
- Computer Graphics and Visualization (BE05000161) syllabus (Gujarat Technological University)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress