Virtual Chunks
Back
Other resources
Here
is another webpage on this website
which discusses virtual chunks.
Download
Here
is the demo package that this webpage describes.
What are Virtual Chunks?
Consider a chunk C1. Let C2 be its parent chunk.
So C2 consists of 16x16x16 blocks.
One of those blocks is C1.
Both C1 and C2 are examples of virtual chunks,
but there are more virtual chunks in C2.
For example, the 2x2x2 regions of blocks in C2
(aligned appropriately) are virtual chunks.
That is, C2 consists of 8x8x8 many
2x2x2 virtual chunks.
Also, C2 consists of 4x4x4 many 4x4x4 virtual chunks.
Finally, C2 also consists of 2x2x2 many 8x8x8 virtual chunks.
These are what virtual chunks are.
Note: here we see it is important that the default chunk width is 16.
If it was not a power of 2, such as 13,
the engine would be in trouble.
The Goal
The purpose of this guide is to explain one way that virtual chunks
are used in procedural world generation.
The main technical function we use is
get_vchunk_data
.
We will explain how to use that function.
The "Chop" Number
Suppose we are procedurally generating a chunk C.
Where N is a power of 2, it may be useful to consider the
unique NxNxN virtual chunk which contains C.
The chunk C itself we call the chop = 0 chunk.
The unique 2x2x2 virtual chunk that contains C
we call the chop = 1 chunk.
The unique 4x4x4 virtual chunk that contains C
we call the chop = 2 chunk.
In general, the chop = k virtual chunk is
2^k by 2^k by 2^k chunks.
Where the name comes from is we have some sort of binary
representation of the block path of C and we are
"chopping off the end".
The chop number is how many bits we chop off of each of the
x,y, and z components.
Example 1: Basic Balls
In this example, we have every chunk contain a sphere of radius 10
with a small probability. What we do is iterate over the 3x3x3
nearby chunks (around the chunk being generated)
and place spheres in them randomly.
When we say random, we mean pseudo random where the random number
generator seed comes from the path of that chunk.
We call the process of getting these spheres
harvesting.
In the following image, the blue chunk is the chunk that is
being procedurally generated.
The green box is a nearby virtual chunk (which in this example
is actually a chunk) that contains a sphere that we want to harvest.
Note: If our spheres had a radius greater than 16.0,
this technique would not work.
Once harvesting is complete, we are left with an array
which consists of the positions (and radii) of spheres which are
all "in the coordinate system of the chunk being generated".
That is, in this coordinate system vectors go from
(0.0, 0.0, 0.0) to (16.0, 16.0, 16.0).
Note: an optimization at this point would be to
throw away all spheres that do not intersect the chunk being
generated.
We are NOT doing that in this example for simplicity.
Now using this array of spheres,
we iterate over all 16x16x16 block positions in the chunk
being generated. If the center of any position is within the
radius of one of the spheres, we make the block solid.
Warning: this can be slow if you are not careful!
Here is the code for the only block type used in this example:
--File: block_ball1_2.lua
function p.__get_is_solid() return true end
function p.__get_tex() return "block_default" end
-------------------------------------------------------------------------
--Lots of spheres.
function p.__main()
set_default_block("e")
--Getting all nearby spheres.
local sphere_array = p.harvest_spheres()
--Iterating over all block positions,
--seeing which ones are close to a sphere.
for x = 0,15 do
for y = 0,15 do
for z = 0,15 do
local bp = std.bp(x,y,z)
local vec = std.block_center(bp)
for i = 1,#sphere_array do
local obj = sphere_array[i]
local r_sq = obj.radius * obj.radius
if h.dist_sq(obj.vec, vec) < r_sq then
set_pos(x,y,z, "s") --Making the position solid.
end
end
end end end
end
--Iterating over the 3x3x3 region of chunks
--around the chunk being generated
--and getting all spheres in these chunks.
function p.harvest_spheres()
local sphere_array = {}
for dx = -1,1 do
for dy = -1,1 do
for dz = -1,1 do
p.harvest_spheres2(sphere_array, dx, dy, dz)
end end end
return sphere_array
end
--Getting spheres from one virtual chunk.
function p.harvest_spheres2(
sphere_array, dx, dy, dz)
--
--Getting vchunk (virtual chunk) data.
local chop = 0
local vd = get_vchunk_data(chop, dx, dy, dz)
srand(vd.seed)
if( randf() > 0.2 ) then
--No spheres in this virtual chunk.
return
end
local new_sphere = {}
--Note: randf() is between 0 and 1.
new_sphere.vec = std.vec(
16.0 * randf() + 16.0*dx,
16.0 * randf() + 16.0*dy,
16.0 * randf() + 16.0*dz)
new_sphere.radius = 10.0 --Radius > 16.0 is a problem.
sphere_array[#sphere_array+1] = new_sphere
end
To make the code above faster, we could precompute the
radius squared of each sphere.
In the code above, dist_sq
is the following function defined in
WorldNodes/Helpers/h.lua:
--Distance squared between two vectors.
function p.dist_sq(v1, v2)
local x = v1.x - v2.x
local y = v1.y - v2.y
local z = v1.z - v2.z
return x*x + y*y + z*z
end
Feel free to replace
local bp = std.bp(x,y,z)
local vec = std.block_center(bp)
with
local vec = std.vec(x,y,z)
in the __main function
(it would be slightly faster, but slightly different).
Frac coordinates
When we talk about the coordinate system of a chunk,
we mean the coordinate system where (0,0,0) and (16,16,16)
are at opposing corners.
Slightly different from this we have
frac coordinates.
These are vectors where (0,0,0) and (1,1,1)
are at opposing corners of the chunk.
Intro to the get_vchunk_data function
We need to start talking about the get_vchunk_data
function.
We used this in Example 1 in the harvest_spheres2
function.
In the picture below, the blue chunk is the chunk that is
being procedurally generated.
The green chunk is a nearby virtual chunk.
We can get information about that virtual function
by calling the get_vchunk_data
function.
Forgiving the author that this is a 2D picture not a 3D picture,
you would call the get_vchunk_data
with a
chop value of 1 (because the virtual chunk is 2^1 by 2^1)
and you would also set dx = -1 and dy = -1.
This is because the green virtual chunk is (-1,-1)
from the virtual chunk that contains the blue chunk
which corresponds to chop = 1.
The get_vchunk_data
returns a Lua table
with various members.
We have just seen one member so far:
seed
.
This is an integer which is somehow associated to the
green virtual chunk.
You pass this to the srand
chunk generation API function
to then generate pseudo random numbers.
This seed is actually what we call a "tail seed", because it only
depends on the last few elements of the block path
of the virtual chunk.
Tail seeds are useful for the engine because there is an efficient
way to find the tail seed of an adjacent chunk.
In this section let us discuss two other
data members of the table returned by
get_vchunk_data
: the members
min2
and
max2
.
These are the coordinates of the corners of the green chunk
but in the coordinate system of the blue chunk.
Recall that in the coordinate system of the blue chunk,
the vectors (0,0,0) and (16,16,16) are on opposing
corners of the blue chunk.
So if you want a puzzle you can verify that
in the 2D picture shown above,
max2 would be the vector
max2 = (-16,0)
and min2 would be the vector
min2 = (-48,-32).
Let us also mention that get_vchunk_data
also
has the members
min
and
max
.
These are just like min2 and max2, except they are
in frac coordinates instead of the chunk coordinates
of the blue chunk.
For example, min is the min corner
of the green chunk, in the frac coordinate system of the blue chunk.
Don't feel like you need to use min and max.
Also, note that it is trivial to convert from min to min2 and from
max to max2 (you just multiply by 16).
Example 2: Basic Balls Revisited
Using what we learned about the get_vchunk_data
function,
we can rewrite the harvest_spheres2
function
to read as follows:
--Getting spheres from one virtual chunk.
function p.harvest_spheres2(
sphere_array, dx, dy, dz)
--
--Getting vchunk data.
local chop = 0
local vd = get_vchunk_data(chop, dx, dy, dz)
srand(vd.seed)
if( randf() > 0.2 ) then
--No spheres in this virtual chunk.
return
end
local new_sphere = {}
local v1 = h.rand_unit_cube()
local v2 = h.interp_box(vd.min2, vd.max2, v1)
new_sphere.vec = v2
new_sphere.radius = 10.0
sphere_array[#sphere_array+1] = new_sphere
end
We have two helper functions for this,
defined in WorldNodes/Helpers/h.lua:
--Random vector in the unit cube.
--The unit cube goes from (0,0,0) to (1,1,1).
function p.rand_unit_cube()
return std.vec(
randf(),
randf(),
randf())
end
and
--Box interpolating.
--If frac == (0,0,0), it will return v1.
--If frac == (1,1,1), it will return v2.
function p.interp_box(v1, v2, frac)
return std.vec(
v1.x * (1.0 - frac.x) + v2.x * frac.x,
v1.y * (1.0 - frac.y) + v2.y * frac.y,
v1.z * (1.0 - frac.z) + v2.z * frac.z)
end
Example 3: Big Balls
We will make a simple modification of Example 2:
we will have the virtual chunks be 2x2x2 instead of 1x1x1.
We will also have the radius of the spheres be 20 instead of 10.
All we have to do is change the code of
harvest_spheres2
to read as follows:
--Getting spheres from one virtual chunk.
function p.harvest_spheres2(
sphere_array, dx, dy, dz)
--
--Getting vchunk data.
local chop = 1
local vd = get_vchunk_data(chop, dx, dy, dz)
srand(vd.seed)
if( randf() > 0.2 ) then
--No spheres in this virtual chunk.
return
end
local new_sphere = {}
local v1 = h.rand_unit_cube()
local v2 = h.interp_box(vd.min2, vd.max2, v1)
new_sphere.vec = v2
new_sphere.radius = 10.0 * vd.scale
sphere_array[#sphere_array+1] = new_sphere
end
That is, there are two modifications from before.
First, we have the line
"chop = 1" instead of "chop = 0".
This is what causes the virtual chunks to be
2x2x2 instead of 1x1x1.
Second, look at the line
new_sphere.radius = 10.0 * vd.scale
In our example, this is equivalent to setting the
sphere radius to be 20, but we are doing it in a more general way.
That is, the scale
member of the Lua table returned by get_vchunk_data
is the width of the virtual chunk divided by the width
of the chunk being procedurally generated.
In our case, because chop = 1, the value of scale is 2.
Example 4: Adding and Subtracting Balls
In this example, we will have large balls and then subtract small
balls from them.
It will look as follows:
We will harvest the large balls from 4x4x4 virtual chunks
and will harvest the small balls from 1x1x1 virtual chunks.
Here is the code for the only block type for this example:
--File: block_ball4_2.lua
function p.__get_is_solid() return true end
function p.__get_tex() return "block_default" end
-------------------------------------------------------------------------
--Small spheres subtracted from large spheres.
function p.__main()
set_default_block("e")
--Getting all nearby (positive and negative) spheres.
local pos_sphere_array = {}
local neg_sphere_array = {}
p.harvest_spheres(
pos_sphere_array,
neg_sphere_array)
--Iterating over all block positions.
--For each position, we see if it is in a positive sphere
--but not a negative sphere.
for x = 0,15 do
for y = 0,15 do
for z = 0,15 do
local bp = std.bp(x,y,z)
local vec = std.block_center(bp)
local is_solid = false
for i = 1,#pos_sphere_array do
local obj = pos_sphere_array[i]
local r_sq = obj.radius * obj.radius
if h.dist_sq(obj.vec, vec) < r_sq then
is_solid = true
end
end
for i = 1,#neg_sphere_array do
local obj = neg_sphere_array[i]
local r_sq = obj.radius * obj.radius
if h.dist_sq(obj.vec, vec) < r_sq then
is_solid = false
end
end
if( is_solid ) then
set_pos(x,y,z, "s") --Making it solid.
end
end end end
end
--Iterating over the 3x3x3 region of chunks
--around the chunk being generated
--and getting all spheres in these chunks.
function p.harvest_spheres(
pos_sphere_array,
neg_sphere_array)
--
--Adding positive spheres.
for dx = -1,1 do
for dy = -1,1 do
for dz = -1,1 do
p.harvest_pos_spheres(pos_sphere_array, dx, dy, dz)
end end end
--Adding negative spheres.
for dx = -1,1 do
for dy = -1,1 do
for dz = -1,1 do
p.harvest_neg_spheres(neg_sphere_array, dx, dy, dz)
end end end
end
function p.harvest_pos_spheres(
pos_sphere_array, dx, dy, dz)
--
--Getting vchunk data.
local chop = 2
local vd = get_vchunk_data(chop, dx, dy, dz)
srand(vd.seed)
if( randf() > 0.2 ) then
--No spheres in this virtual chunk.
return
end
local new_sphere = {}
local v1 = h.rand_unit_cube()
local v2 = h.interp_box(vd.min2, vd.max2, v1)
new_sphere.vec = v2
new_sphere.radius = 10.0 * vd.scale
pos_sphere_array[#pos_sphere_array+1] = new_sphere
end
function p.harvest_neg_spheres(
neg_sphere_array, dx, dy, dz)
--
--Getting vchunk data.
local chop = 0
local vd = get_vchunk_data(chop, dx, dy, dz)
srand(vd.seed)
if( randf() > 0.2 ) then
--No spheres in this virtual chunk.
return
end
local new_sphere = {}
local v1 = h.rand_unit_cube()
local v2 = h.interp_box(vd.min2, vd.max2, v1)
new_sphere.vec = v2
new_sphere.radius = 8.0 * vd.scale
neg_sphere_array[#neg_sphere_array+1] = new_sphere
end
Example 5: Smooth Balls
In this example, we will make balls of radius 20,
but when you shrink and approach the balls, you
can see that they are smooth.
So when you are small and right next to a ball,
it will look huge.
The idea is that chunks on one level L will harvest
spheres using chop = 1.
Then, the chunks of level L+1 will harvest spheres
using chop = 1 + 4.
Why 4?
This is because every time you shrink one level,
the world grows by a factor of 16 = 2^4.
When writing worldgen code like this, there is a temptation
to duplicate certain code.
However for sanity, it is important that certain code is
reused by multiple block scripts.
We will indicate where this is important.
Here is the block script for the unique block type on level L:
--File: block_ball5_2.lua
function p.__get_is_solid() return true end
function p.__get_tex() return "block_default" end
-------------------------------------------------------------------------
--Lots of relatively normal sized spheres.
function p.__main()
local solid_bt = "block_ball5_3_solid"
local empty_bt = "block_ball5_3_empty"
set_default_block(empty_bt)
--Getting all nearby spheres.
local sphere_array = p.harvest_spheres()
--Iterating over all block positions,
--seeing which ones are close to a sphere.
for x = 0,15 do
for y = 0,15 do
for z = 0,15 do
local bp = std.bp(x,y,z)
local vec = std.block_center(bp)
for i = 1,#sphere_array do
local obj = sphere_array[i]
local r_sq = obj.radius * obj.radius
if h.dist_sq(obj.vec, vec) < r_sq then
set_pos(x,y,z, solid_bt)
end
end
end end end
end
--Iterating over the 3x3x3 region of chunks
--around the chunk being generated
--and getting all spheres in these chunks.
function p.harvest_spheres()
local sphere_array = {}
local chop = 1
for dx = -1,1 do
for dy = -1,1 do
for dz = -1,1 do
p.harvest_spheres_common(sphere_array, chop, dx, dy, dz)
end end end
return sphere_array
end
--Will be called from other scripts.
--Notice how chop is an arg.
function p.harvest_spheres_common(
sphere_array, chop, dx, dy, dz)
--
--Getting vchunk data.
local vd = get_vchunk_data(chop, dx, dy, dz)
srand(vd.seed)
if( randf() > 0.2 ) then
--No spheres in this virtual chunk.
return
end
local new_sphere = {}
local v1 = h.rand_unit_cube()
local v2 = h.interp_box(vd.min2, vd.max2, v1)
new_sphere.vec = v2
new_sphere.radius = 10.0 * vd.scale
sphere_array[#sphere_array+1] = new_sphere
end
So the __main
function calls the
harvest_spheres
function, which in turn
calls the harvest_spheres_common
function.
However the harvest_spheres_common
function is
written in such a way that it can be called from other
block scripts.
That is, it will be called by the block scripts for blocks
on level L+1.
We see that the __main
creates two types of blocks:
the solid block_ball5_3_solid
blocks
(which look like solid blocks from a distance) and
the empty block_ball5_3_empty
blocks
(which look like empty blocks from a distance).
This is what the block_ball5_3_solid
block script looks like:
--File: block_ball5_3_solid.lua
function p.__get_is_solid() return true end
function p.__get_tex() return "block_default" end
-------------------------------------------------------------------------
--Will be called from another script.
function p.common_main()
set_default_block("e")
--Getting all nearby spheres.
local sphere_array = p.harvest_spheres()
--Iterating over all block positions,
--seeing which ones are close to a sphere.
for x = 0,15 do
for y = 0,15 do
for z = 0,15 do
local bp = std.bp(x,y,z)
local vec = std.block_center(bp)
for i = 1,#sphere_array do
local obj = sphere_array[i]
local r_sq = obj.radius * obj.radius
if h.dist_sq(obj.vec, vec) < r_sq then
set_pos(x,y,z, "s")
end
end
end end end
end
--Huge spheres.
function p.__main()
p.common_main()
end
--Iterating over the 3x3x3 region of chunks
--around the chunk being generated
--and getting all spheres in these chunks.
function p.harvest_spheres()
local sphere_array = {}
--We add an extra 4 to chop because we are one level higher
--than block_ball5_2.
local chop = 1 + 4
for dx = -1,1 do
for dy = -1,1 do
for dz = -1,1 do
--Note: beware of duplicating certain worldgen code.
block_ball5_2.harvest_spheres_common(sphere_array, chop, dx, dy, dz)
end end end
return sphere_array
end
So the main function of block_ball5_3_solid
and block_ball5_3_empty
will be identical.
Rather than duplicate code (which in this case would be a bad idea)
we just have the main function of both block scripts
call a common helper function.
The main function of block_ball5_3_solid
calls a harvest function in the same script.
That harvest function calls the
harvest_spheres_common
function in
block_ball5_2
.
This is a situation where if we duplicated code instead, it
would be a nightmare to maintain.
All that remains is to talk about the
block_ball5_3_empty
script.
Luckily, this is trivial:
--File: block_ball5_3_empty.lua
function p.__get_is_solid() return false end
function p.__get_tex() return "" end
-------------------------------------------------------------------------
--Huge spheres.
function p.__main()
--Note: beware of duplicating certain worldgen code.
block_ball5_3_solid.common_main()
end
Note: a clever optimization is to have 4 blocks on level L+1 instead of 2.
That is, we could have the two we just described but also a truly empty
block type and a truly solid block type.
We would use the truly solid block type for the interior of the spheres.
We would use
block_ball5_3_solid
and
block_ball5_3_empty
for blocks
on the boundary of a sphere.
For all blocks sufficiently outside the boundary,
we would use the truly empty block type.
Honestly, this 4 block approach seems like a hassle.