What shape does a landscape have, sampled on a grid? surface draws a sheet through your samples. It is the last mark in the kernel to gain a renderer, and the only one that draws in the cube alone. Every other mark was built in the plane, and some have since learned to stand up. A surface is a sheet through three positions, and there is no such thing without the third.
It is not area with a z. An area fills between a curve and a baseline, and a surface has no baseline to close on; the family resemblance is that both are fills, and it stops there.
20.1 A sheet through the samples
Here is Maunga Whau, the volcano in Auckland that is included with R, on a 20 m grid. Ross Ihaka digitized the elevations from a topographic map, and they reach this page through R’s datasets package (R Core Team, 2026):
plot(data(maunga_whau), surface,x(col.east),y(col.north),z(col.elevation),z_label("Elevation (m)"),title("Maunga Whau, at 20 m"))
“Given Maunga Whau: a surface, x is east, y is north, z is elevation.”
Three positions and a mark. There is no space() in that sentence and none is needed: binding z is what puts a plot in the cube, exactly as it is for a 3-D scatter. space(turn =, tilt =) still sets the angle when you want a different one.
20.2 One row per crossing
Handed two bare position columns, a surface reads its rows as nodes, and the sheet is the faces between them. You never declare which nodes are neighbors. gog recovers that from your two position columns: the distinct east values become the mesh’s columns and the distinct north values its rows. A face is drawn wherever all four corners of a cell are present.
So the table this reading wants is one row per (x, y) crossing, which is the shape expand.grid() makes:
head(maunga_whau, 4)
east north elevation
1 20 20 100
2 20 40 102
3 20 60 104
4 20 80 105
nrow(maunga_whau)
[1] 1364
1364 rows, 31 distinct eastings and 44 distinct northings: 31 × 44 = 1364, so the grid is full, and 30 × 43 = 1290 faces get drawn. That arithmetic is the whole contract, and it is why a scatter cannot be a surface. Sixty points in general position describe a 60 × 60 lattice with sixty nodes in it and not one complete block of four, so the sheet would come out empty:
Error:
! gog: `surface` found no complete cell to draw a face on — 142 rows over 142 distinct `gdp` and 142 distinct `life` values, which is a scatter rather than a grid. A surface needs one row per (x, y) crossing, the shape `expand.grid()` makes. For a cloud of scattered points use `point` with `z(population)`; to estimate a field *from* them use `surface * density + x(gdp) + y(life) + space()`.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
That refusal names the two sentences that do draw, and the second of them is the interesting one. Read on.
Because a face needs all four of its corners, a gap in the grid is an opening in the sheet rather than a face drawn across it. Drop the crossings around the summit and the mesh tears rather than inventing a lid:
gog: `surface` has 1322 of 1364 crossings filled, so the sheet is drawn with 42 open — a face needs all four of its corners. That is a true picture of a grid with gaps; if you expected a whole sheet, check that `east` and `north` repeat exactly across rows rather than to a rounded value.
The note above the plot is gog counting the crossings it did not find: 1322 of 1364, so 42 are open. It is advice rather than a refusal, because a grid with gaps is a legitimate thing to have and a torn sheet is a true picture of one. What would not be legitimate is quietly spanning a gap, which asserts a height nobody measured. The note is also the thing that tells you when the gaps were an accident: a lattice recovered from coordinates that were rounded rather than repeated has holes everywhere, and looks exactly like a mesh with a designed opening.
20.3 A continuous floor
Both position columns have to be numbers. A face spans the gap between two samples and so asserts every height inside that gap, and between one category and the next there is no inside to assert anything about:
Error:
! gog: `x(continent)` maps a categorical (text) column, but `x` on `surface` needs a continuous (numeric) column. A face spans the gap between two samples, and between two categories there is nothing to span. For a mesh over categories use `bar * count + x(<a>) + y(<b>) + space()` — a column stands in its own cell and claims nothing in between.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
The direction there points at the mark whose floor may be slotted. A 3-D bar stands in its cell and claims nothing about the space between cells, so bar * count + x(a) + y(b) + space() is a mesh over categories and a surface is not. A category owns a slot with air on either side of it, and tiles that float apart are not a sheet.
Numbers cut into cells are a different matter, and a surface takes those: the section on one value per cell, below. So the two marks part on the slot rather than on the cut, which is the same cut-versus-slot distinction the flat marks make, read one dimension up.
20.4 The third geometry of one field
A surface takes two transforms, and density is the one that makes it the third way gog draws a field. The same estimate over the same two columns, three times:
data(iris_flowers) + surface * density +x(petal_length) +y(sepal_length) +space() +title("As a sheet: surface")
(data(iris_flowers) + surface * density + x(col.petal_length) + y(col.sepal_length) + space() + title("As a sheet: surface"))
data(iris_flowers) + surface * density +x(:petal_length) +y(:sepal_length) +space() +title("As a sheet: surface")
plot(data(iris_flowers),layer(surface, density),x(col.petal_length),y(col.sepal_length),space(),title("As a sheet: surface"))
Look at what the third one shows that the other two cannot: how much higher the tight cluster is than the broad one. Cells and contours both spend their information on where the field is, and a height spends it on how much, the two setosa-versus-the-rest blobs are equally visible in the first two plots and wildly unequal in the third. Which of the three you want is a question about your question, not about which is better.
One transform, three marks, and the estimate itself is the same in all three. What differs is where the measurement goes: each mark puts it where its own geometry measures. A zone measures by color. A path measures by the level the contour was cut at. A surface measures by height. That is why the third sentence never binds z() and still gets an elevation axis: the transform supplies the measurement, and the mark knows where it belongs. A flat bar * bin does the same for y.
Here the transform stays the same and the mark chooses the geometry. That is the rule that already made the contour and the estimated heatmap one feature instead of two.
space() is in that sentence and not in the volcano’s, for a reason worth knowing. A bound z says there are three columns here, which nothing else can mean. An invented one says only this layer has a measure of its own. Since density invents one in the plane too, it needs the coordinate to say where the answer goes.
20.5 When each cell holds one value
bin is the other transform, and it draws a different sheet: flat across each cell, stepping at the boundary. Here is the same volcano, read as a coarse grid rather than a fine one:
data(maunga_whau) + surface *bin(12) * mean +x(east) +y(north) +z(elevation) +z_label("Elevation (m)") +title("Maunga Whau, one elevation per cell")
(data(maunga_whau) + surface *bin(12) * mean + x(col.east) + y(col.north) + z(col.elevation) + z_label("Elevation (m)") + title("Maunga Whau, one elevation per cell"))
data(maunga_whau) + surface *bin(12) * mean +x(:east) +y(:north) +z(:elevation) +z_label("Elevation (m)") +title("Maunga Whau, one elevation per cell")
plot(data(maunga_whau),layer(surface,bin(12), mean),x(col.east),y(col.north),z(col.elevation),z_label("Elevation (m)"),title("Maunga Whau, one elevation per cell"))
Nothing about the mountain changed. What changed is the claim. The sheet at the top of this chapter says the ground rises smoothly between its samples; this one says each cell has a single elevation and nothing is asserted about the ground inside it. Both are honest about different data, and which you want is a question about how yours was measured.
That question has a sharp answer whenever a design produces exactly one value per cell, which is the ordinary case in an experiment or a simulation: a grid of conditions, one number in each. Read as nodes, such a table loses most of itself. Nine values on a 3 × 3 grid make 2 × 2 blocks of four corners, so the sheet is four faces, and it spans only from the first sample to the last instead of covering the grid:
data(cells) + surface *bin(3) * mean +x(:a) +y(:b) +z(:v) +color(:v) +palette("plasma") +title("Nine values, nine plateaus")
plot(data(cells),layer(surface,bin(3), mean),x(col.a),y(col.b),z(col.v),color(col.v),palette("plasma"),title("Nine values, nine plateaus"))
Nine plateaus, and three colors. This bowl is symmetric, so the four corner cells share one height and the four edge cells share another. The ramp is what makes that symmetry visible, where the sheet above left it to be read off the axis.
bin cuts the two floor axes into cells, and mean reduces the column named on z inside each one. That is the sentence a 3-D bar already used. The transforms mean the same thing on both marks, and only the geometry laid on the cell differs. Where a table holds one row per cell, as this one does, the mean of a single value is that value, and the transform is doing nothing but naming the column.
The step between two plateaus is drawn, as a riser standing on the line the two cells share. That line has no width, so the riser asserts nothing about any area: it is simply what a jump looks like. Without it the mark would draw disconnected tiles floating at their own heights, and a reader could not tell a step from a missing cell.
A riser spans the difference between two neighbors, and that is what separates this from standing a 3-D bar on every cell. A bar’s wall runs the whole way from the baseline, so a floor of them walls itself off as soon as you turn it, while risers are only as tall as the local step. Turn both and watch which survives. Both are set to one color, so the only thing that differs between them is the shape:
| composes them (Composition), so the two are one figure and one drag. Turn either and both turn together, which is what makes this a fair comparison rather than two pictures you have to line up by eye.
The height belongs to the figure, and two cubes side by side are why it is stated. They divide the page’s width and each keep the whole of its height. A cube is fitted with one scale on both axes, so it never stretches to fill a tall cell. The room it cannot use would sit empty above it and below it.
Empty cells need no special rule and get none. bin leaves them out, because a count of zero and no data at all are different things, and a plateau claims only its own cell, so a cell with nothing in it is simply a cell with no lid. That is the same opening a missing crossing makes in a node mesh, arrived at from the other direction.
What a surface still refuses is a floor of slots. count and proportion tally into the cells two categories make, and categories leave air:
Error:
! gog: a `surface` is a sheet over a floor whose cells tile without gaps, and `count` does not give it one — `smooth` fits a curve along a domain a cell has none of, `range`/`confidence`/`bounds` give a low and a high where a cell holds one height, and `count`/`proportion` tally into the cells two *categories* make, which a surface refuses because slots leave air between them and disconnected tiles are not a sheet. A surface takes the two transforms that do tile: `bin` cuts the floor into adjacent cells and lays a flat lid on each — `surface * bin * mean + x(<a>) + y(<b>) + z(<column>)` reduces the column you name inside every cell — and `density` estimates a value at every node, which the sheet then interpolates between: `surface * density + x(<a>) + y(<b>) + space()`. Over categories, `bar` is the mark, where the column under each tile says which cell it belongs to.
gog: `x(continent)` maps a categorical (text) column, but `x` on `surface` needs a continuous (numeric) column. A face spans the gap between two samples, and between two categories there is nothing to span. For a mesh over categories use `bar * count + x(<a>) + y(<b>) + space()` — a column stands in its own cell and claims nothing in between.
gog: `y(era)` maps a categorical (text) column, but `y` on `surface` needs a continuous (numeric) column. A face spans the gap between two samples, and between two categories there is nothing to span. For a mesh over categories use `bar * count + x(<a>) + y(<b>) + space()` — a column stands in its own cell and claims nothing in between.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
20.6 The mesh, and what the shading means
border_color and border_size are the mesh lines, and they are the reading that made a rim worth having on this mark. A face is a closed fill like a bar’s or a box’s, so the settable rule hands it a border; on a mesh, that border is the wireframe:
It costs nothing to draw, because every face was already stroked in its own shade, the hairline that closes the pale seam antialiasing leaves between two abutting polygons. border_color just replaces that color. Set border_size = 0 for a seamless sheet.
Notice what the shading is doing in both plots, and what it is not. The faces are shaded by their slope: a steep one is darker, a level one keeps the color whole. That is a fact about the terrain, not about where a light is, so turning the scene rearranges the faces on the page and repaints none of them:
plot(data(maunga_whau), surface,x(col.east),y(col.north),z(col.elevation),space({ turn:130,tilt:35 }),z_label("Elevation (m)"),title("Turned, not relit"))
60040020008006004002000180160140120100EastNorthElevation (m)Turned, not relit
Compare the crater walls here with the first plot. Same faces, same shades, seen from the other side. A lamp fixed to the page would have repainted every one of them, and a hue that moves while the data sits still is the kind of wrongness a reader cannot see.
20.7 Color by height, per face
color maps on a surface, and a measured column ramps it face by face. palette() chooses which ramp, and "viridis" is the one a topographic sheet wants: it climbs in lightness the whole way, so the height reads off the hue even where a face is turned away from you:
data(maunga_whau) + surface +x(east) +y(north) +z(elevation) +color(elevation) +palette("viridis") +z_label("Elevation (m)") +title("Height twice: as a position and as a ramp")
(data(maunga_whau) + surface + x(col.east) + y(col.north) + z(col.elevation) + color(col.elevation) + palette("viridis") + z_label("Elevation (m)") + title("Height twice: as a position and as a ramp"))
data(maunga_whau) + surface +x(:east) +y(:north) +z(:elevation) +color(:elevation) +palette("viridis") +z_label("Elevation (m)") +title("Height twice: as a position and as a ramp")
plot(data(maunga_whau), surface,x(col.east),y(col.north),z(col.elevation),color(col.elevation),palette("viridis"),z_label("Elevation (m)"),title("Height twice: as a position and as a ramp"))
60040020008006004002000180160140120100EastNorthElevation (m)Height twice: as a position and as a rampElevation193.0143.594.00
Compare that with the first plot in this chapter. The summit and the crater rim were there all along, and slope shading alone left them to be inferred from which faces were dark; the ramp says the heights out loud, and the key decodes them.
Mapping the height to color when it is already the height is redundant on purpose, and it is what a reader of a topographic map expects, so the ramp earns its place. It also marks a real boundary. An area refuses a measured color, because a region has one interior and coloring an interior by a measure is a gradient fill, which is a different and much larger job. A mesh has faces, and a face is already small enough to hold one value. So the rule was never “regions refuse it, strokes take it”; it is whether a mark has parts small enough to each hold one value, and a stroke has segments where a surface has faces.
A face has four corners and one color. What it reads is the field at its center, which is the mean of those four, so the ramp describes the mesh you have and not only the field you sampled from. Sample the same bowl twice and watch the shape arrive:
bowl <-function(n) { g <-expand.grid(a =seq(-2, 2, length.out = n), b =seq(-2, 2, length.out = n)) g$v <-0.019+0.0025* (g$a^2+ g$b^2) g}coarse <-bowl(3)fine <-bowl(7)((data(coarse, name ="coarse") + surface +x(a) +y(b) +z(v) +color(v) +palette("plasma") +title("Sampled 3 by 3: four faces")) | (data(fine, name ="fine") + surface +x(a) +y(b) +z(v) +color(v) +palette("plasma") +title("Sampled 7 by 7: thirty-six faces"))) +theme(height =320)
210-1210-1-20.0400.0350.0300.0250.020ABVSampled 3 by 3: four facesV0.040.030.02210-1210-1-20.0400.0350.0300.0250.020ABVSampled 7 by 7: thirty-six facesV0.040.030.02
Two tables on one page, so each is named. A layer finds its columns in the nearest table by that name, and two tables answering to the same one would leave the second unreachable (Data).
The left sheet is one flat color, and that is honest rather than broken. Each of its four faces spans one corner at every height this bowl has, so all four average to the same number. The mesh is too coarse to hold the shape, and the ramp says so instead of inventing a gradient across it. The right sheet has the same field under it and enough faces to resolve it, so the bowl appears.
This is the reading that makes the key trustworthy. A color is the height at one named place on the sheet, so two faces of one color really are at one height, and a sheet of one color really is flat on average.
The slope shading is still underneath the ramp, and the two compose rather than fight: a face takes its color from the ramp and is then darkened by how steep it is. Viridis survives that because only the low ground reaches its dark end, and on a terrain the low ground is also the flattest. The two darkenings therefore do not pile up, and the darkest face on this sheet is lighter than a fully-shaded viridis floor would have been.
An estimated height ramps the same way, and you name it the same way. A density publishes its estimate under the name density, so that is the word color takes, exactly as it does on a zone or a path:
data(iris_flowers) + surface * density +x(petal_length) +y(sepal_length) +space() +color(density) +palette("viridis") +title("The estimate, as a height and as a ramp")
(data(iris_flowers) + surface * density + x(col.petal_length) + y(col.sepal_length) + space() + color(col.density) + palette("viridis") + title("The estimate, as a height and as a ramp"))
data(iris_flowers) + surface * density +x(:petal_length) +y(:sepal_length) +space() +color(:density) +palette("viridis") +title("The estimate, as a height and as a ramp")
plot(data(iris_flowers),layer(surface, density),x(col.petal_length),y(col.sepal_length),space(),color(col.density),palette("viridis"),title("The estimate, as a height and as a ramp"))
864208640.150.100.050.00Petal LengthSepal LengthDensityThe estimate, as a height and as a rampDensity0.160.080
Nothing there is a special case. The transform makes a column, the column has a name, and a name can be bound. Whether the height came from your table or from an estimate changes who wrote the number, and it does not change what you may say about it.
Whether you want the second reading is a separate question, and the plain sheet earlier in this chapter is the one that makes the argument about height. Use the ramp when the low ground matters: slope shading darkens a steep face and leaves a level one alone, so a broad, shallow mound and the flat floor around it are the same color, and the ramp is what separates them.
A categorical color splits the mark instead, one sheet per group, and the depth sort runs over every face of every series at once so two sheets thread through each other rather than one landing wholly in front:
A still 3-D plot hides something at every angle, and which thing it hides is the angle’s choice. So it is worth turning the same sheet rather than trusting one view of it. Each of these changes exactly one of turn/tilt from the first:
below(beside(plot(data(maunga_whau), surface,x(col.east),y(col.north),z(col.elevation),color(col.elevation),palette("viridis"),space({ turn:30,tilt:25 }),z_label("Elevation (m)"),title("The reference three-quarter view")),plot(data(maunga_whau), surface,x(col.east),y(col.north),z(col.elevation),color(col.elevation),palette("viridis"),space({ turn:140,tilt:25 }),z_label("Elevation (m)"),title("Turned: the same sheet from the west"))),beside(plot(data(maunga_whau), surface,x(col.east),y(col.north),z(col.elevation),color(col.elevation),palette("viridis"),space({ turn:30,tilt:70 }),z_label("Elevation (m)"),title("Tilted up: the crater appears")),plot(data(maunga_whau), surface,x(col.east),y(col.north),z(col.elevation),color(col.elevation),palette("viridis"),space({ turn:30,tilt:5 }),z_label("Elevation (m)"),title("Tilted down: the skyline"))))
60040020008004000180160140120100EastNorthElevation (m)The reference three-quarter viewElevation193.0143.594.0060020008004000180160140120100EastNorthElevation (m)Turned: the same sheet from the westElevation193.0143.594.0060040020008004000180140100EastNorthElevation (m)Tilted up: the crater appearsElevation193.0143.594.0060020008006004002000180160140120100EastNorthElevation (m)Tilted down: the skylineElevation193.0143.594.00
Read them as a set and the trade is plain. The crater is not visible at all in the first two, and it is the most interesting thing about this hill: the summit stands in front of the hollow behind it, so no amount of turning at a low eye level reveals it. Tilt the eye up and the hollow opens, because you are now reading the plan, and by the same move the heights flatten, until the sheet is a colored map of itself. Tilt down instead and you get the opposite bargain: the profile is exact, the plan is gone, and the sheet reads as a skyline. Heights or plan, one at a time.
That is the argument for a plot you can turn, made rather than asserted. Reading both the plan and the profile is not a convenience here. It is the only way to see this hill whole, and no single still view offers it.
So drag the tour with the mouse. Lift your eye until the crater opens, and watch the heights flatten as it does. The four still views make the argument; dragging them is how you check it.
All four turn together, because | and / composed them into one figure and a composition has one drag. Each panel keeps the angle its own sentence asked for and the drag adds the same change to every one, so the four stay the four: they turn as a set, and reset returns each to where it began. To turn one alone, write it alone.
That is composition rather than faceting, and the two share the same pair of operators. A facet names a column (Faceting), and a viewing angle is not a column: it is a property of the coordinate space, the way a polar() start angle is. So a small multiple of cubes is built, and these four panels are still not one (Space shows the real thing), because a facet over view angles would need a column to name and there is none.
One honest edge in this tour, and it is not a surface’s fault. Notice the vertical axis in the tilted-up panel. Its numbers sit tight against each other, because that axis is projecting at about a third of its length and they are still all being drawn. They do clear each other, and tilting further would make the frame leave some out rather than overlap them. Tight is simply what a foreshortened axis looks like. Space says what the frame does about it, and what it deliberately does not do to the scale.
20.9 What a surface is for
Anything that is a height over a plane. Terrain and bathymetry. A response surface from a designed experiment. A likelihood or loss over two parameters. A fitted model’s predictions over a grid of two predictors. And, through density, the shape of a two-dimensional distribution.
What it is not for is data that merely has three numbers per row. That is a 3-D scatter, and the refusal above will send you there. The question to ask is whether the space between two of your samples means anything. On a grid it does; in a cloud it does not.
20.10 What you can set
Everything a surface can be told to look like, generated from the engine’s own rule table so this page cannot drift from what style() actually accepts:
Setting
Value
style(color = )
any CSS color name or hex
style(opacity = )
0 to 1
style(border_color = )
any CSS color name or hex
style(border_size = )
pixels
And these vary per row if you map them to a column instead: color() (either), group() (categories), play() (either).
A surface is a fill, so it takes a fill’s settings, with two absences worth noting, both of which the refusals below explain: no size, and no pattern.
20.11 What it refuses
A surface with no height is the same mistake as a surface in the plane, so it is one refusal rather than two, and it names both routes into the cube along with the mark that draws a field in the plane:
data(maunga_whau) + surface +x(east) +y(north)
Error:
! gog: a `surface` is a sheet through three positions, so it needs the cube. Bind the height — `surface + x(a) + y(b) + z(h)` — or let a transform invent it: `surface * density + x(a) + y(b) + space()`. For the same field drawn in the plane, `zone` paints it as cells and `path` traces its contours.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
density(levels = ) cuts a field into level sets, and a level set is a region in the plane. A surface has already spent the third axis on the measurement, so a band drawn on it could only be a color, which is zone’s reading of that same request:
Error:
! gog: `density(levels = )` cuts a field into level sets, which are regions in the plane — a `surface` draws the field itself, with the estimate as its height, so it has no axis left to put a band on. Drop `levels` for the sheet, or `zone * density(levels = )` to fill the bands and `path * density(levels = )` to trace their boundaries. For a sheet in steps, cut the floor rather than the height: `surface * bin * mean + x(<a>) + y(<b>) + z(<column>)` lays a flat plateau on every cell.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
A stepped sheet is a real thing, and there are two of them, cut on different axes. Cutting the floor into cells is surface * bin, the section on one value per cell above, and it draws. Cutting the height into bands is what levels asks for, and that one is not drawn: it would quantize the measurement rather than the grid, so the plateaus would be contours of equal height rather than one per cell.
bandwidth is refused for the reason it is refused on every two-dimensional field: it is a length in one column’s own units, and a field spreads over two columns measuring different quantities, so one number cannot be a width in both. Use density(adjust = ), which is dimensionless and scales both.
There is no size on a surface: its extent is its lattice, so a size would be a second, contradictory answer to how big it is, which is area’s refusal for area’s reason. And pattern is refused, which is the one place a fill does not take a texture:
Error:
! gog: `style(pattern = )` textures a fill, and a `surface` is a mesh of fills — but a hatch tile is a texture in *screen* space, and a projected mesh foreshortens every face differently, so one tile would read as a different density on every face. That is a texture that changes with the viewing angle instead of with the data. A surface says its shape with slope shading; `style(border_color = )` draws its mesh lines, and `color` ramps it.
gog: nothing was rendered. Fix the above, or set GOG_STRICT=0 to draw anyway.
A hatch tile is a texture in screen space, and every face of a projected mesh is foreshortened differently, so one tile size would read as a different density on every face. That is a texture varying with the viewing angle instead of with the data, which is exactly the light this mark’s shading is defined not to be.
R Core Team. (2026). R: A language and environment for statistical computing. R Foundation for Statistical Computing. https://www.R-project.org/