# Frustum Desk > Read one camera and say what it can contain, what it can resolve, and what it is > drawing that nobody can see. A frustum is four numbers that are not independent: > `fov` is VERTICAL so the aspect decides the horizontal shot, `near` and `far` > interact through their RATIO so raising near is worth ten times what lowering far is, > a shadow map is a SECOND frustum with the same arithmetic, and culling tests a > bounding SPHERE rather than the object. Free browser-side geometry, five paid lanes. Live at https://frustum-desk.skillsafe.ai/ · API at https://frustum-desk.skillsafe.ai/api.html Derived from the `threejs-fundamentals` and `threejs-lighting` skills in `cloudai-x/threejs-skills` (https://github.com/cloudai-x/threejs-skills). Not affiliated with or endorsed by the three.js project or by cloudai-x. ## The one thing to know **`fov` is VERTICAL, so the aspect decides the horizontal shot.** ```text hFov = 2 * atan(tan(vFov/2) * aspect) vFov 50 deg on a 1440x900 canvas -> hFov 73.5° vFov 50 deg on a 390x844 canvas -> hFov 24.3° ``` The vertical never moves and the horizontal swings 49.1°. `PerspectiveCamera` takes a vertical angle and the aspect is recomputed from the element on every resize, so there is no horizontal field of view to set - only the one the canvas gives you. **`near` and `far` interact through their RATIO, and near is the whole dial.** ```text dz = z^2 * (1/near - 1/far) / (2^B - 1) all three at the SAME z = 1000 units, 24-bit: near 0.1 far 10000 -> 59.6 cm near 1 far 10000 -> 5.96 cm <- 10x the near plane, 10× the precision near 0.1 far 1000 -> 59.6 cm <- 10x the far plane, 1.00009× ``` **And the buffer is spent where nothing is.** At near 0.1 and far 10000 the first 1% of the view range consumes 99.9% of it. Z-fighting in the distance is not a mesh problem - there was never any resolution out there to spend. **A shadow map is a SECOND frustum.** A directional light's shadow camera is orthographic, so its texel density is exactly `mapSize / span` and is uniform: 512² over 100 puts one texel at 19.53 cm and 2048² puts it at 4.88 cm. Halving the span is worth exactly as much as doubling the map, and costs nothing instead of four times the memory. Its near and far obey the same arithmetic, which is what shadow acne is. **And culling tests a bounding SPHERE.** A plane 100x100 has a bounding radius of 70.7 against its own half-extent of 50 - 41.4% of slack - so it is submitted to the GPU from 20.7 outside the frustum, every frame, and nobody can see it. ## The horizontal field of view | Canvas | Aspect | Vertical fov | Horizontal fov | Half-width at 10 units | Half-height at 10 units | | --- | --- | --- | --- | --- | --- | | `desktop` 1440x900 | 8:5 | 50° | **73.5°** | 7.461 | 4.663 | | `laptop` 1280x800 | 8:5 | 50° | **73.5°** | 7.461 | 4.663 | | `tablet` 1024x1366 | 512:683 | 50° | **38.5°** | 3.496 | 4.663 | | `phone` 390x844 | 195:422 | 50° | **24.3°** | 2.155 | 4.663 | One camera at 50°. **The vertical column never moves and the horizontal spans 49.1°**, because `PerspectiveCamera(fov, aspect, near, far)` takes a VERTICAL angle and the aspect is recomputed from the element on every resize. There is no horizontal field of view to set — there is only the one the canvas gives you. ## The depth ladder | near | far/near | Step at 10 units | Step at 100 units | Step at 1000 units | Buffer used by the first 1% | Half the buffer gone by | | --- | --- | --- | --- | --- | --- | --- | | **0.01** | 1000000× | 0.596 mm | 5.96 cm | 5.96 units | 99.9901% | 0.02 | | **0.1** | 100000× | 0.0596 mm | 0.6 cm | 59.6 cm | 99.9% | 0.2 | | **1** | 10000× | 0.006 mm | 0.596 mm | 5.96 cm | 99% | 2 | | **10** | 1000× | 0.595 µm | 0.0595 mm | 0.6 cm | 91% | 20 | `far` is fixed at 10000 and `near` moves, at 24 bits. **Every step figure scales exactly with `1/near`** — ten times the near plane is ten times the precision at every distance — and the last two columns are why: at a small near plane almost the whole buffer is consumed before the scene even starts. ## The far-plane control | far | far/near | Step at 100 units | Against the widest far | | --- | --- | --- | --- | | **100** | 1000× | 5.954504 mm | 1.001× | | **1000** | 10000× | 5.959869 mm | 1.0001× | | **10000** | 100000× | 5.960405 mm | 1.00001× | | **100000** | 1000000× | 5.960459 mm | 1× | The SAME experiment on the other dial: `near` is fixed at 0.1 and `far` moves by three orders of magnitude. **The last column barely leaves 1×.** `dz` depends on `1/near − 1/far`, and once `far` is much larger than `near` the second term is already negligible — so pulling the far plane in is very nearly free, and very nearly worthless. This is the table to read next to the one above. ## The shadow ladder | Map | Span | Texels per unit | One texel is | Texels rasterised | Verdict | | --- | --- | --- | --- | --- | --- | | 512² | 10 | **51.2** | 1.95 cm | 0.26 Mpx | fine | | 512² | 50 | **10.24** | 9.77 cm | 0.26 Mpx | **coarse** | | 512² | 100 | **5.12** | 19.53 cm | 0.26 Mpx | **coarse** | | 1024² | 100 | **10.24** | 9.77 cm | 1.05 Mpx | **coarse** | | 2048² | 100 | **20.48** | 4.88 cm | 4.19 Mpx | fine | | 4096² | 200 | **20.48** | 4.88 cm | 16.78 Mpx | fine | | 2048² | 20 | **102.4** | 0.98 cm | 4.19 Mpx | fine | A directional light's shadow camera is ORTHOGRAPHIC, so its texel density is uniform and exactly `mapSize / span`. **Read rows 3 and 6 together**: 512² over 100 and 4096² over 200 are not comparable, but 2048² over 100 and 4096² over 200 are IDENTICAL in every visible respect — and the second costs four times the memory and four times the fill. Halving the span is always worth exactly as much as doubling the map. ## What culling actually tests | Object | Bounding radius | Its own half-extent | Slack | Slack as a share | | --- | --- | --- | --- | --- | | box 1x1x1 (a cube) | 0.866 | 0.5 | 0.366 | **73.2%** | | box 10x1x10 (a slab) | 7.089 | 5 | 2.089 | **41.8%** | | plane 100x100 (ground) | 70.7 | 50 | 20.7 | **41.4%** | | plane 1000x1000 (terrain) | 707.1 | 500 | 207.1 | **41.4%** | | sphere r=1 | 1 | 1 | 0 | **0%** | | cylinder r=0.5 h=20 (a pole) | 10 | 10 | 0.012 | **0.12%** | Frustum culling tests the object's **bounding sphere**, which is circumscribed and therefore always overstates. A cube's sphere is 73% wider than the cube; a flat ground plane's is 41% past its own edge; a long thin pole's is worse still. That slack is the region in which an object is submitted to the GPU and cannot be seen, and splitting the object into tiles is the only fix that keeps culling switched on. ## Constants and thresholds | Constant | Value | What it decides | | --- | --- | --- | | `RATIO_WASTEFUL` | 1000× | a far/near ratio worth warning about | | `RATIO_SEVERE` | 10000× | one a fixed-point buffer cannot hold usefully | | `HFOV_SWING_DEG` | 10° | a horizontal swing across canvases worth naming | | `SHADOW_TEXEL_M` | 5 cm | a shadow texel worth calling coarse | | `DEFAULT_FOV` | 50° | assumed when the sheet states no fov — three.js's own default | | `DEFAULT_NEAR` / `DEFAULT_FAR` | 0.1 / 2000 | assumed planes, and near is the one that decides every precision figure | | `DEFAULT_DEPTH_BITS` | 24 | assumed buffer — what a browser gives you | | `DEFAULT_DPR` | 2 | assumed device pixel ratio | Only the projection arithmetic is three.js's; everything else is this page's threshold for when a difference is worth saying. The angles, the depth steps and the texel sizes are measurements, not preferences — the thresholds decide only what gets called a warning. ## Sheet grammar A sheet is a header of `KEY: value` lines, an `OBJECTS:` block, and an optional `LIGHTS:` block. ```text JOB: what this shot is (optional, echoed back) CANVAS: 1440x900, 390x844 (a comma list; one camera is a different shot in each, which is the point) DPR: 2 (device pixel ratio; assumed 2) CAMERA: fov=50 near=0.1 far=10000 at=0,1.6,6 look=0,1,0 DEPTH: 24 (one of 16, 24, 32, log; assumed 24) OBJECTS: at=x,y,z [feature=] LIGHTS: type=directional map= span= near= far= ``` **`fov` is the VERTICAL angle** — three.js's `PerspectiveCamera` takes one, and the aspect comes from the canvas. Assumed 50°, which is three.js's own default. **`near` is the dial and `far` very nearly is not.** Depth precision is `z² × (1/near − 1/far) / (2^B − 1)`, so state `near` even when nothing else is known: it is assumed 0.1 and it decides every precision figure on the answer. A shape is one of `box`, `plane`, `sphere`, `cylinder`, sized `WxHxD` for a box, `WxH` for a plane, `R` for a sphere and `RxH` for a cylinder. `feature=` names the smallest detail that has to survive; without it the object's own smallest dimension is used. A light with a `map=` and a `span=` is a SECOND FRUSTUM with its own near, far and texel density, and every consequence on this page applies to it too. Anything the reader cannot place is listed as a problem rather than skipped. A sheet whose object lines were quietly swallowed would be a smaller scene than the one described, and every count and extent on this page would be too generous. ## Lanes | Lane | What it produces | | --- | --- | | `plan` | Turn a shot description into a camera | | `audit` (primary) | What this camera can see, resolve, and is drawing for nothing | | `frame` | The framing question: one vertical angle, many horizontals | | `depth` | The precision question: near, far, and what cannot be separated | | `decide` | Decide what changes: a number, the scene, or the shot | ## A worked sheet ```text JOB: the product hero, on a desktop and on a phone CANVAS: 1440x900, 390x844 DPR: 2 CAMERA: fov=50 near=0.1 far=10000 at=0,1.6,6 look=0,1,0 DEPTH: 24 OBJECTS: ground plane 60x60 at=0,0,0 product box 1.2x1.8x0.6 at=0,0.9,0 plinth box 2x0.1x2 at=0,0.05,0 LIGHTS: key type=directional map=1024 span=40 near=0.5 far=500 fill type=directional map=512 span=40 near=0.5 far=500 ``` | Object | Bounding radius | View depth | Depth step there | Its smallest feature | Submitted | Visible | | --- | --- | --- | --- | --- | --- | --- | | `ground` plane 60x60 | 42.4 | 6.129 | 0.0224 mm | 60 units | yes | yes | | `product` box 1.2x1.8x0.6 | 1.122 | 6.04 | 0.0217 mm | 60 cm | yes | yes | | `plinth` box 2x0.1x2 | 1.415 | 6.124 | 0.0224 mm | 10 cm | yes | yes | The sheet in the worked example. 50° vertical over 2 canvases, horizontal 24.3° to 73.5°, near/far 0.1/10000 = 100000×, 3 objects, 1 with a coarse shadow map. **Read the last two columns together**: a row that is submitted and not visible is a draw call for nothing, and it happens because culling tests the bounding sphere in column two rather than the object in column one. ## Findings | Finding | Severity | Scope | What it says | | --- | --- | --- | --- | | `NO-SCENE-TO-PROJECT` | error | sheet | The sheet has no camera and no objects | | `THE-PROJECTION-IS-UNDEFINED` | error | depth | near is not positive, or far is not beyond near | | `THE-NEAR-FAR-RATIO-WASTES-THE-DEPTH-BUFFER` | warning | depth | far/near is large enough that most of the buffer is unusable | | `AN-OBJECT-IS-UNDER-THE-DEPTH-RESOLUTION` | warning | depth | The depth buffer cannot separate an object from what it sits on | | `AN-OBJECT-IS-BEYOND-THE-FAR-PLANE` | warning | scene | An object is entirely past far and will never be drawn | | `AN-OBJECT-STRADDLES-THE-NEAR-PLANE` | warning | scene | A solid is partly closer than near, so it will be cut open | | `THE-SHOT-CHANGES-WITH-THE-CANVAS` | warning | frame | The horizontal field of view swings across the canvases | | `AN-OBJECT-DOES-NOT-FIT-EVERY-CANVAS` | warning | frame | An object is framed on one canvas and cropped on another | | `A-DRAW-CALL-IS-SUBMITTED-FOR-SOMETHING-INVISIBLE` | warning | scene | Culling keeps an object that cannot be seen | | `THE-SHADOW-MAP-CANNOT-RESOLVE-THE-SCENE` | warning | shadow | One shadow texel is larger than the detail that has to cast a shadow | | `THE-SHADOW-FRUSTUM-DOES-NOT-CONTAIN-THE-SCENE` | warning | shadow | Part of the scene is outside the shadow camera | | `THE-SHADOW-FRUSTUM-WASTES-ITS-OWN-DEPTH-BUFFER` | warning | shadow | The shadow camera has the same near/far problem as the view camera | | `THE-SHADOW-MAPS-COST-MORE-THAN-THE-FRAME` | warning | shadow | The shadow maps rasterise more pixels than the canvas does | | `THE-SHEET-HAS-LINES-THIS-PAGE-COULD-NOT-READ` | warning | sheet | Some lines were not readable | | `WHERE-THE-DEPTH-BUFFER-IS-SPENT` | note | depth | Almost the whole depth buffer is used up close to the camera | | `RAISING-NEAR-BEATS-LOWERING-FAR` | note | depth | A ten-fold change to near and to far are not worth the same | | `THE-HORIZONTAL-FIELD-OF-VIEW-IS-NOT-A-SETTING` | note | frame | fov is vertical; the horizontal comes from the canvas | | `EVERY-CANVAS-SHARES-AN-ASPECT` | note | frame | The canvases agree, so the shot does not move | | `THE-DISTANCE-THAT-FITS-AN-OBJECT` | note | frame | Framing an object is an exact camera distance | | `A-BOUNDING-SPHERE-IS-BIGGER-THAN-ITS-OBJECT` | note | scene | How much slack culling is working with | | `AN-OBJECT-IS-BEHIND-THE-CAMERA` | note | scene | An object is behind the camera and is culled immediately | | `THE-DRAWING-BUFFER-IS-NOT-THE-CANVAS` | note | frame | The device pixel ratio multiplies every pixel figure | | `A-SHADOW-MAP-IS-A-SECOND-FRUSTUM` | note | shadow | Each casting light is another camera with its own settings | | `DOUBLING-THE-MAP-AND-THE-SPAN-CANCELS` | note | shadow | Resolution and coverage trade exactly against each other | | `NOTHING-CASTS-A-SHADOW` | note | shadow | The sheet declares no casting light | | `A-LOGARITHMIC-DEPTH-BUFFER-CHANGES-THE-TRADE` | note | depth | A logarithmic depth buffer makes the error linear rather than quadratic | 26 findings: 2 errors, 12 warnings and 12 notes. **Nothing that fires on every usable sheet is a warning.** The horizontal field of view always differs from the vertical, the depth buffer is always spent near the camera, and a bounding sphere is always bigger than its object — so those are notes. What gets a warning is a number past a threshold or a surface that cannot be resolved. ## What this page cannot do This page reads a description of a scene, not your code. It never creates a WebGL context, renders a frame or measures a GPU, so: - **It is the PROJECTION and nothing else.** Materials, textures, draw-call batching, overdraw, shader cost and instancing all decide what a frame costs and none of them changes what the frustum contains. What is exact here is the geometry of seeing. - **Every object is an axis-aligned box, plane, sphere or cylinder.** A real mesh has a bounding sphere computed from its vertices and a bounding box that may be rotated, so the slack figures here are the *shape* of the answer for your mesh rather than the answer itself. The direction of the error is always the same: a real mesh's sphere is at least this loose, never tighter. - **The depth arithmetic assumes a FIXED-POINT buffer and the standard projection.** That is what a browser gives you, but a reversed-Z projection with a float buffer distributes precision almost uniformly and none of the near/far arithmetic here applies to it. WebGL cannot do reversed-Z; WebGPU can. - **The shadow figures are for a DIRECTIONAL light's orthographic camera.** A spot light's shadow camera is perspective, so its texel density varies with distance and `mapSize / span` is only its best case. A point light needs six faces and costs six times the fill. - **Cascades are outside it.** A cascaded shadow map is several second frustums with several spans, which is exactly the trade this page prices — but which one covers which range is a choice this page does not model. - **Nothing here says whether the shot is any good.** It says what this camera can contain, what it can resolve, and what it is drawing that nobody can see. ## API `POST https://api.skillsafe.ai/v1/app-api/run` with an app session token. The body IS the input object — never wrapped in an `input` key. Fields: `task` (one of `plan`, `audit`, `frame`, `depth`, `decide`), `rules`, `prescan`, plus the lane's own fields. `POST .../estimate` with the same body returns `hold_credits` and costs nothing. ## Provenance Every table and every figure above is generated from `frustum.js` by `build-skill.js`. Nothing is typed twice, so nothing can drift.