Gama Network
Presents:

Continuous LOD Terrain Meshing
Using Adaptive Quadtrees
By Thatcher
Ulrich
Gamasutra
February 28, 2000
URL: http://www.gamasutra.com/features/20000228/ulrich_01.htm
Terrain rendering is a perennial
hot issue in the world of game programming. Right now we're at a particularly
interesting point in the development of terrain rendering technology,
because polygon budgets have risen to the point where, in conjunction
with real-time LOD meshing algorithms taken from published academic
papers, state-of-the-art game engines are able to draw quite a bit
of reasonably detailed terrain. However, the techniques which
are currently in common use must compromise either on terrain size or
on close-up detail.
As part of the
R&D for Soul Ride, the game I'm currently working on (http://www.soulride.com/ ), I experimented
with the published algorithms, and eventually came up with an
extension that eliminates the tradeoff between terrain size and
close-up detail. This article
presents my algorithm, along with its similarities and differences
from the above-mentioned
algorithms.
I'll start by
reviewing the problem of terrain rendering, and describe the problem
solved by [1], [2], and [3] (see references at the end of this article). Then I'll explain the
additional problem solved by my algorithm.
I'll present a detailed description of the algorithm, and
discuss some of the problems with it and some of the untapped
potential. And last but
not least, I'll provide the source code to a demo that implements my
algorithm, which you can use to help understand it, evaluate its
effectiveness, and incorporate directly into your own projects if you
want.
This article is not a
general tutorial or review of terrain rendering. I'm going to assume some familiarity on
your part with the problem.
If things aren't making much sense, you may want to consult
the excellent references listed at the end of the
article.
The
Problems
What do we want from
a terrain renderer? We want a
single continuous mesh from the foreground all the way to the
horizon, with no cracks or T-junctions. We want to view a large area over a
large range of detail levels: we want to see the bumps in front of
our feet to the mountains in the background. For the sake of discussion, let's
say that we want feature size to range from 1m up to 100000m; five
orders of magnitude.
How can we
do it? The brute-force approach
won't work on ordinary computers circa Y2K. If we make a 100000m x 100000m grid of
16-bit height values, and just draw them in a mesh (Figure 1), we'll
end up with two big problems.First, the triangle problem:
we'll be sending up to 20 billion triangles/frame to our rendering
API. Second, the memory
problem: our heightfield will consume 20 GB of data. It will be many years before
hardware advances to the point where we can just use brute-force and get good
results.
 |
Fig 1. Brute force approach to a heightfield
mesh. |
There are
several previously-published methods which successfully tackle the
triangle problem. The most widely
used ones employ a clever family of recursive meshing algorithms [1],
[2], [3]. Using one of these algorithms, we can effectively tame our
mesh, and render a seamless terrain with a few thousand triangles,
with the vertices intelligently selected on the fly from the 10
billion in the dataset.
However, we still
have a memory problem, since the heightfield dataset consumes 20 GB
(plus some overhead to support the meshing
algorithm).
One obvious solution
is to compromise on detail by making the heightfield dimensions
smaller. 1k x 1k is a good
practical size for a heightfield with today's machines. A recently released game called
TreadMarks uses a 1k x 1k dataset to excellent effect [4] .
Unfortunately, 1k x 1k is still a far cry from 100k x 100k. We end up having to limit
either the size of the terrain and the view distance, or the amount
of foreground detail.
The solution which I
cover in this article is to use an adaptive quadtree, instead of a
regular grid, to represent the terrain height information. Using this quadtree, we can encode
height data at different resolutions in different regions in the
terrain. For example, in
a driving game, you would want lots of fine detail on and around the
roads, ideally showing every bump, but you wouldn't need that much
detail for the surrounding wilderness that you can't drive to; you
only need enough detail for the general shape of hills and
valleys.
The quadtree can also
be used for another attack on the memory problem: procedural
detail. The idea is to pre-define
the shape of the terrain at a coarse level, and have the computer
automatically generate fine detail on the fly for the area
immediately around the viewer.
Because of the quadtree's adaptive nature, this detail can be
discarded when the viewer moves, freeing up memory for creating
procedural detail in a different
region.
Separately, the use
of quadtrees for adaptive representation of 2D functions, and the use
of quadtrees for recursive meshing [1], [3] are both well-known. However, [1] and [3] both use
regular grids for their underlying heightfield representation.
Extending their meshing approach to work with a true adaptive
quadtree presents numerous complications, and requires some tricky
programming. Hence this
article and the accompanying demo code.
Meshing
My meshing algorithm
is based on [1], which has also influenced [2] and [3]. There are a few key modifications, but
much of the basic approach is the same, and I borrow a lot of the
[1] terminology.
There are two parts
to meshing. I call the first part
Update() and the second part Render(), after [1]. During Update(), we'll decide
which vertices to include in the output mesh. Then, during Render() we'll generate a
triangle mesh that includes those vertices. I'll start by
explaining Update() and Render() for an extremely simple heightfield:
a 3x3 grid (Figure 2). To Update()
it, we'll look at each of the optional vertices and decide whether to
include them in the mesh.
Following the terminology of [1], we'll say that if and only
if a vertex is "enabled", then we'll use it in the mesh.
 |
Figure 2. A 3x3
heightfield. Dashed lines and vertices are optional in an LOD
mesh. |
Take as
given that the center and corner vertices are enabled. So the task is to calculate
the enabled state for each of the four edge vertices, according to
some LOD calculation which takes the viewpoint and the vertex
attributes into account.
Once we know which
vertices are enabled, we can Render() the mesh. It's easy; we just
make a triangle fan with the center vertex as the hub, and include
each enabled vertex in clockwise order around the outside. See Figure 3 for examples.
 |
Figure 3. Examples of LOD meshes on the 3x3 heightfield.
Disabled vertices in black.
|
To
Update() and Render() an adaptive quadtree heightfield, we extend the
above process by starting with that same 3x3 square and recursively
subdividing it. By subdividing, we
can introduce new vertices, and treat them like we treated the
vertices of the original square. In order to prevent cracks, however,
we'll have to observe some
rules.
First, we can
subdivide any combination of the four quadrants. When we subdivide a quadrant,
we'll treat the quadrant as a sub-square, and enable its center
vertex. For mesh consistency, we
will also have to enable the edge vertices of the parent square which
are corners of the quadrant (Figure 4). We'll define enabling a square to
imply the enabling of its center vertex as well as those
corners.
 |
Figure 4.
Subdividing the NE quadrant of a square. The gray vertices are already
known to be enabled, but the black vertices must be enabled when we
subdivide. |
Next, notice that an
edge vertex in a sub-square is shared with a neighboring sub-square
(except at the outside edges of our terrain). So when we enable an
edge vertex, we will have to make sure that the neighboring
sub-square which shares that vertex is also enabled (Figure 5). Enabling this neighbor square can in
turn cause other vertices to be enabled, potentially propagating
enabled flags through the quadtree. This propagation is necessary to ensure
mesh consistency. See
[1] for a good description of these dependency
rules.
 |
Figure 5. While
updating the NE quadrant, we decide to enable the black vertex. Since that
vertex is also shared by the SE quadrant (marked in gray), we must enable
that quadrant also. Enabling the SE quadrant will in turn force us to
enable the gray vertices. |
After we're done with
the Update(), we can Render() the quadtree. Rendering is actually
pretty simple; the complicated consistency stuff was taken care of in
Update(). The basic strategy is
to recursively Render() any enabled sub-squares, and then render
any parts of the square which weren't covered by enabled
sub-squares. (Figure 6) shows an example
mesh.
 |
Figure 6. An example mesh. Enabled vertices are marked in
black. The gray triangles are drawn by recursive calls to Render() on the
associated sub-squares. The white triangle are drawn by the original call
to Render(). |
Evaluating vertices
and squares
In the above
description, I glossed over the part about deciding whether a vertex
should be enabled. There are a few
different ways to do this.
All of them take into account what I'll call the "vertex
interpolation error", or vertex error for short. What this is, is the
difference in height between the correct location of a vertex, and
the height of the edge in the triangle which approximates the vertex
when the vertex is disabled (Figure 7).
Vertices which have a large error should be enabled in
preference to vertices which have a small error. The other key variable that goes into
the vertex enable test is the distance of the vertex from the
viewpoint. Intuitively,
given two vertices with the same error, we should enable the closer
one before we enable the more distant
one.
 |
Figure 7.
Vertex interpolation error. When a vertex is enabled or disabled, the mesh
changes shape. The maximum change occurs at the enabled vertex's position,
shown by the dashed line. The magnitude of the change is the difference
between the true height of the vertex (black) and the height of the
original edge below the vertex (white). The white point is just the
average of the two gray points. |
There are
other factors that can be included as well. [1] for instance takes into
account the direction from the viewpoint to the vertex. The justification is based on the idea
of screen-space geometric error; intuitively the vertex errors are
less visible when the view direction is more vertical. [1] goes through the math in
detail.
However, I don't
think screen-space geometric error is a particularly good metric, for
two reasons. One, it ignores
texture perspective and depth buffering errors -- even if a vertex does not move
in screen space because the motion is directly towards or away from the
viewpoint, the vertex's view-space z value does affect perspective-correction as
well as depth-buffering. Two, the viewpoint-straight-down case is both
an easy case for terrain LOD algorithms, and not a typical case.
In my
opinion, there's no point in optimizing for an atypical easy case in
an interactive system. The
performance of the more typical and difficult case, when the view
axis is more horizontal and much more terrain is visible, will
determine the minimum system frame-rate and hence the effectiveness
of the algorithm.
Instead of
screen-space geometric error, I advocate doing a similar test which
results in 3D view-space error proportional to view distance. It's really very similar to the
screen-space-error test, but without the issues I mention above. It involves only three
quantities: an approximation of the viewpoint-vertex distance called
the L1-norm, the vertex error, and a detail threshold constant. Here it
is:
L1 = max(abs(vertx - viewx), abs(verty - viewy), abs(vertz - viewz));
enabled = error * Threshold < L1;
You probably
recognize the L1-norm, even if you didn't know it had a fancy
name. In practice, using the
L1-norm instead of the true viewpoint distance will result in
slightly more subdivision along the diagonals of the horizontal
terrain plane. I've never been able
to detect this effect by eye, so I don't worry much about it. [4] and others use
view-space-z rather than the L1-norm, which is theoretically even
more appropriate than true viewpoint distance. Nevertheless, the
L1-norm works like a champ for me, and [3] uses it
too.
You can treat the
Threshold quantity as an adjust-for-best-results slider, but it does
have an intuitive geometric interpretation. Roughly, it means: for a
given view distance z, the worst vertex error I'll tolerate is z /
Threshold. You could do some
view-angle computations and relate Threshold to maximum pixel error,
but I've personally never gone past the adjust-for-best-results
stage.
So that
covers the vertex enabled test. But
if you were paying attention earlier, you may also have noticed that
I glossed over another point, perhaps more important: during Update(), how do
we know whether to subdivide a quadrant or not? The answer is to do what I
call a "box test". The box test
asks the question: given an axis-aligned 3D box enclosing a portion
of terrain (i.e. a quadtree square), and the maximum vertex error
contained within that box, and no other information about what's
inside the box, is it possible that the vertex enable test would
return true? If so, then we
should subdivide the box.
If not, then there's no reason to subdivide.
The beauty of it is,
by doing the box test, we can potentially trim out thousands of
vertices from consideration in one fell swoop. It makes Update() completely
scalable: its cost is not related to the size of the full dataset,
only to the size of the actual data that's included in the current
LOD mesh. And as a side benefit,
the precomputed vertical box extent can be used during Render()
for frustum culling.
The box test is
conservative, in that a square's max-error could be for a vertex on the opposite
side of the box from the viewpoint, and thus the vertex test itself
would/will fail for that actual vertex, whereas the box test might
succeed. But once we subdivide,
e'll go ahead and do four more, more accurate box tests on the
sub-squares, and the penalty for conservatism is fairly small: a few
extra vertex and box tests, and a couple extra vertices in the
mesh.
Fortunately, given the
above simple vertex test, a suitable box test is easy to
formulate:
bc[x,y,z] ==
coordinates of box center
ex[x,y,z] == extent
of box from the center (i.e. 1/2 the box dimensions)
L1 = max(abs(bcx -
viewx) - exx, abs(bcy - viewy) - exy, abs(bcz - viewz) -
exz)
enabled = maxerror *
Threshold < L1
Details
That covers the
essentials of the algorithm. What's
left is a mass of details, some of them crucial.
First of all, where is the height data actually stored? In all of the
previously-published algorithms, there is a regular grid of height
values (and other bookkeeping data), on top of which the mesh is
implicitly [1] & [3] or explicitly [3] defined. The key innovation of my
algorithm is that the data is actually stored in an adaptive
quadtree. This results in two major
benefits. First, storage
can be allocated adaptively according to the actual dataset or the
needs of the application; e.g. less storage can be used in smoother
areas or areas where the viewpoint is not expected to travel. Second, the tree can grow or shrink
dynamically according to where the viewpoint is; procedural detail
can be added to the region near the viewpoint on-the-fly, and deleted
when the viewpoint moves on.
In order to store
heightfield information in a quadtree, each quadtree square must
contain height values for at least its center vertex and two of its
edge vertices. All of the other
vertex heights are contained in other nearby nodes in the tree. The heights of the corner
vertices, for instance, come from the parent quadtree square. The
remaining edge vertex heights are stored in neighboring squares. In
my current implementation, I actually store the center height and all
four edge heights in the quadtree square structure. This simplifies things
because all the necessary data to process a square is readily
available within the square or as function parameters. The upshot is
that the height of each edge vertex is actually stored twice in the
quadtree.
Also, in
my current implementation, the same quadtree used for heightfield
storage is also used for meshing.
It should be possible to use two separate heightfields, one
for heightfield storage and one for meshing. The potential benefits of such an
approach are discussed later.
A lot of
the tricky implementation details center around the shared edge
vertices between two adjacent squares.
For instance, which square is responsible for doing the
vertex-enabled test on a given edge vertex? My answer is to arbitrarily say that a
square only tests its east and south edge vertices. A square relies on its
neighbors to the north and to the west to test the corresponding edge
vertices.
Another interesting
question is, do we need to clear all enabled flags in the tree at the
beginning of Update(), or can we proceed directly from the state left
over from the previous frame? My
answer is, work from the previous state (like [2], but unlike [1]
and [4]). Which leads to more details: we've already
covered the conditions that allow us to enable a vertex or a square,
but how do we know when we can disable a vertex or a square? Remember from the original
Update() explanation, the enabling of a vertex can cause dependent
vertices to also be enabled, rippling changes through the tree. We can't just disable a
vertex in the middle of one of these dependency chains, if the vertex
depends on enabled vertices.
Otherwise we'd either get cracks in the mesh, or important
enabled vertices would not get
rendered.
If you take a look at
Figure 8, you'll notice that any given edge vertex has four adjacent
sub-squares that use the vertex as a corner. If any vertex in any of
those sub-squares is enabled, then the edge vertex must be
enabled. Because the square itself
will be enabled whenever a vertex within it is enabled, one approach
would be to just check all the adjacent sub-squares of an edge vertex
before disabling it.
However, in my implementation, that would be costly, since
finding those adjacent sub-squares involves traversing around the
tree. Instead, I maintain a
reference count for each edge vertex. The reference count records the
number of adjacent sub-squares, from 0 to 4, which are enabled. That means that every time a square
is enabled or disabled, the reference counts of its two adjacent
edge vertices must be updated.
Fortunately, the value is always in the range [0,4], so we can
easily squeeze a reference count into three
bits.
 |
Figure 8. Each
edge vertex has four adjacent sub-squares which use it as a corner. If
any of those squares are enabled, then the edge vertex must be
enabled. For example, the black vertex must be enabled if any of the
four gray squares are enabled. |
Thus the
disable test for an edge vertex becomes straightforward: if the
vertex is currently enabled, and the associated reference count is
zero, and the vertex test with the current viewpoint returns false,
then disable the edge vertex.
Otherwise leave it alone. The conditions for
disabling a square are fairly straightforward: if the square is
currently enabled, and it's not the root of the tree, and none of its
edge vertices are enabled, and none of its sub-squares are enabled,
and the square fails the box test for the current viewpoint, then
disable it.
Memory
A very important
issue with this (or any) LOD method is memory consumption. In a fully populated quadtree, a single
quadtree square is equivalent to about three vertices of an ordinary
heightfield, so it is imperative to keep the square data-structure as
compact as possible.
Fortunately, the needs of the Update() and Render() algorithms
do not require each square to contain all the information about 9
vertices. Instead, this is the
laundry list of required data:
- 5 vertex heights (center, and
edges verts east, north, west, south)
- 6 error values (edge verts east
and south, and the 4 child squares)
- 2 sub-square-enabled reference
counts (for east and south verts)
- 8 1-bit enabled flags
(for each edge vertex and each child square)
- 4 child-square
pointers
- 2 height values for min/max
vertical extent
- 11-bit 'static' flag, to mark
nodes that can't be deleted
Depending on the
needs of the application, the height values can usually be squeezed
comfortably into 8 or 16 bits. The
error values can use the same precision, or you can also do some
non-linear mapping voodoo to squeeze them into smaller data
sizes. The reference
counts can fit into one byte along with the static flag. The enabled
flags fit in one byte. The size of
the child-square pointers depends on the maximum number of nodes you
anticipate. I typically
see node counts in the hundreds of thousands, so I would say 20 bits
each as a minimum. The min/max
vertical values can be squeezed in various ways if desired, but 8
bits each seems like a reasonable minimum. All told, this amounts to at least 191
bits (24 bytes) per square assuming 8-bit height values. 16-bit height values bring
the total to at least 29 bytes. A
32-byte sizeof(square) seems like a good target for a thrifty
implementation. 36 bytes is what
I currently live with in Soul Ride, because I haven't gotten
around to trying to bit-pack the child pointers. Another byte-saving trick I
use in Soul Ride is to use a fixed-pool allocator replacement
for quadquare::new() and delete(). You can eliminate whatever overhead
the C++ library imposes (at least 4 bytes I would expect) in favor of
a single allocated bit per square.
There are various
compression schemes and tricks that could be used to squeeze the data
even smaller, at the expense of complexity and performance
degradation. In any case, 36 bytes
per 3 vertices is not entirely unrespectable. That's 12 bytes/vertex. [1] reports implementations
as small as 6 bytes per vertex. [2]
only requires storage of vertex heights and "wedgie thicknesses", so
the base data could be quite tiny by comparison. [4], using a modified [2], reports the
storage of wedgie thicknesses at a fraction of the resolution of the
height mesh, giving further savings.
However,
such comparisons are put in a different light when you consider that
the quadtree data structure is completely adaptive: in very smooth
areas or areas where the viewer won't ever go near, you need only
store sparse data. At the same
time, in areas of high importance to the game, you can include very
detailed features; for example the roadway in a driving game can have
shapely speed bumps and potholes.
Geomorphs
[2] and [3] go into
some detail on "vertex morphing", or "geomorphs". Basically, geomorphing is a technique
whereby when vertices are enabled, they smoothly animate from their
interpolated position to their correct position. It looks great and eliminates
unsightly popping; see McNally's TreadMarks for a nice
example.
Unfortunately, doing
geomorphs requires storing yet another height value for the vertices
that must morph, which would present a real data-size problem for the
adaptive quadtree algorithm as I've implemented it. It could result in adding several bytes
per square to the storage requirements, which should not be done
lightly. [3] incurs the same per-vertex storage penalty, but [2]
avoids it because it only has to store the extra height values for
vertices that are actually in the current mesh, not for every vertex
in the dataset.
I have three
suggestions for how to address the geomorph issue. The first alternative is to
spend the extra memory. The
second alternative is to optimize the implementation, so that really
small error tolerances would be practical and geomorphs
unnecessary. Moore's Law may take care of this fairly soon without
any additional software work.
The third alternative is to split the quadtree into two trees,
a "storage tree" and a "mesh tree".
The storage tree would hold all the heightfield information
and precomputed errors, but none of the transitory rendering data
like enabled flags, reference counts, geomorph heights, etc. The mesh tree would hold all that stuff,
along with links into the storage tree to facilitate expanding the
mesh tree and accessing the height data.
The mesh tree could be relatively laissez-faire about memory
consumption, because its size would only be proportional to the
amount of currently-rendered detail. Whereas the storage tree, because
it would be static, could trim some fat by eliminating most of the
child links.
The
storage-tree/mesh-tree split could also, in addition to reducing
total storage, increase data locality and improve the algorithm's
cache usage.
Working
Code
The Soul Rider
engine is closed source for the forseeable future, but I did
re-implement the essentials of this algorithm as a companion demo for
this article. The demo source
is freely available for you to examine, experiment with, and modify
and incorporate into your own commercial or non-commercial
projects. I only ask that if you
do incorporate the demo source into a project, please acknowledge me
in the credits!
I didn't sweat the
data-packing issue in the demo code.
That would be a good area to experiment with. Also, I didn't implement frustum
culling of squares, but all the necessary data is readily available.
The data included
with the demo comes from USGS 7.5-minute DEMs of the Grand Canyon
(USGS). At Slingshot we have a
proprietary tool that crunches the USGS data and stitches neighboring
DEMs together; I collected 36 datasets and resampled them at a lower
resolution to make the heightfield. I made the texture in a few minutes
in Photoshop, by loading an 8-bits per sample version of the
heightfield as raw data, running the Emboss filter on it to create
shading, and adding some noise and tinting. The texture is just one big
1024x1024 image, stretched over the entire
terrain.
The data-loading code
should be fairly self explanatory, so if you have some of your own
data you want to try, it should be easy to get it in
there.
The program uses
OpenGL and GLUT for 3D, window setup, and input. I developed it under Win98
using a TNT2 card, but I tried to avoid Windows-isms so it should be
easy to port to other systems that support
GLUT.
Exercises for the
Reader
In addition to the
tighter data packing I mentioned, there are a few other things in the
Soul Ride engine which aren't in the article demo. The big one is a unique-full-surface
texturing system, the details of which are beyond the scope of this
article. But I will
mention that good multi-resolution texturing, especially containing
lighting, is extremely beneficial for exploiting the unique features
of the quadtree terrain algorithm.
One thing I haven't
yet experimented with, but looking at the demo code would be fairly
easy to hack in, is on-demand procedural detail. In my view,
on-demand procedural detail looms large in the future of computer
graphics. There just doesn't seem
to be any other good way to store and model virtual worlds to the
detail and extent where they really have the visual richness of the
real world. Fortunately, the
problem is completely tractable, if complicated. I think this quadtree
algorithm, because of its scalability, can be helpful to other
programmers working on on-demand procedural
detail.
Yet another cool
extension would be demand-paging of tree subsections. It actually doesn't seem too difficult;
basically you'd flag certain quadsquares at any desired spot in the
hierarchy as being "special"; they'd contain links to a whole giant
sub-tree stored on disk, with the max-error for the sub-tree
precomputed and stored in the regular tree. Whenever Update() would try to enable
a "special" square, it would actually go off and load the sub-tree
and link it in before continuing. Getting it to all stream in in the
background without hitching would be a little interesting, but
I I think doable. It
would result in basically an infinite paging framework. On-demand procedural detail could
exploit the same basic idea; instead of chugging the disk drive to
get pre-made data, you'd run a terrain-generation algorithm to make
the data on the fly.
And
another suggestion for further work would be to identifying and
eliminating performance bottlenecks.
I suspect that there's some headroom in the code for making
better use of the graphics API
interface.
Acknowledgements
In
addition to the authors of the papers (listed below under References)
which this work is based on, I would also like to send shout-outs to
Jonathan Blow, Seumas McNally and Ben Discoe for their various
thought-provoking emails and comments, and also to the participants
in the algorithms@3dgamedev.com mailing
list, where I've learned a lot of extremely interesting stuff from
other programmers about different approaches and the ins-and-outs of
terrain rendering.
References:
[1] Peter Lindstrom,
David Koller, William Ribarsky, Larry F. Hodges, Nick Faust and
Gregory A. Turner. "Real-Time,
Continuous Level of Detail Rendering of Height Fields". In SIGGRAPH 96 Conference
Proceedings, pp. 109-118, Aug
1996.
[2] Mark
Duchaineau, Murray Wolinski, David E. Sigeti, Mark C. Miller, Charles
Aldrich and Mark B. Mineev-Weinstein.
"ROAMing Terrain: Real-time, Optimally Adapting Meshes." Proceedings of the
Conference on Visualization
'97, pp. 81-88,
Oct 1997.
[3] Stefan Ròttger,
Wolfgang Heidrich, Philipp Slusallek, Hans-Peter Seidel. Real-Time Generation of Continuous
Levels of Detail for Height Fields. Technical Report 13/1997, Universit„t
Erlangen-NËrnberg.
[4] Seumas
McNally. http://www.longbowdigitalarts.com/seumas/progbintri.html. This is a good practical
introduction to Binary Triangle Trees from [2]. Also see http://www.treadmarks.com/, a game which
uses methods from [2].
[5] Ben
Discoe, http://www.vterrain.org/ . This web site is an excellent
survey of algorithms, implementations, tools and techniques related
to terrain rendering.
Thatcher Ulrich
is the lead programmer for Slingshot Game Technology http://www.sshot.com/, which is working hard to
make a snowboarding game that doesn't suck. You can gaze at Thatcher's vacation
photos at http://www.tulrich.com/, or
email him at tu@tulrich.com.
Copyright À 2000-2001 CMP Media
Inc. All rights
reserved.