License: Apache-2.0
Convert laser scans into 3D Tiles you can stream into Unreal Engine through Cesium. Single files or whole directories.
Hundreds of gigabytes of laser scan, streamed into Unreal in seconds. The right panel is the octree: only the tiles in view load, at the detail the camera needs.
One dependency: entwine2tiles. It does the octree and the tiles. Everything
else is standard library Python.
-
Get entwine2tiles, a fork of Entwine carrying the 3D Tiles writer that upstream removed in 2019. Pick whichever suits you:
# conda, and the binary lands on PATH. # conda-forge is required: libpdal-core and libpdal-e57 come from there. conda install -c amerchant -c conda-forge entwine2tiles # or a container, if you would rather not install anything docker pull ghcr.io/arkounm/entwine2tiles:latest # or build it from source: see that repository's README
It links PDAL, GDAL, PROJ, curl and OpenSSL, so a bare binary dropped on a release page would not run. Conda and the container are the two supported deliveries.
-
Put it on your
PATH, or setENTWINE2TILES_EXEto its path, or edit the default inconfig.py. See Configuration. -
Check it resolved:
python config.py
-
Run it:
python main.py -i input.laz -o output/ --longitude <lon> --latitude <lat> --height <height>
Skip the geolocation flags and you still get a tileset, it will just sit in Ottawa (the author's test location) rather than where the scan was captured. It prints a warning if you do this.
-
Load
output/<basename>/tileset.jsonin Cesium for Unreal, or any 3D Tiles viewer.
- Octree:
entwine2tiles buildwrites an EPT octree - Tiles:
entwine2tiles convertwrites 3D Tiles 1.1 with glTF content - Geolocation: an east-north-up transform places the tileset on the globe
- Gzip (on by default, disable with
--no-gzip)
Stage 1 is nearly all of the wall-clock. On a 354 MB scan of 13.2 million points: 19s to build the octree, 1s to write 320 tiles, and the rest is noise.
- Python 3.9+ (
--gzip/--no-gzipneedsargparse.BooleanOptionalAction) - entwine2tiles, from conda or the container image
That is the whole list. Prior versions of this pipeline needed Entwine, PDAL, Blender, Node, 3d-tiles-tools, and LAStools, six separate installs, because the middle of the pipeline went through PLY and Blender to reach glTF.
The pipeline shells out to an executable, so a wrapper script that forwards to
docker run works as ENTWINE2TILES_EXE. Both the input and the output have
to be visible inside the container at the same paths the pipeline passes, so
mount them rather than relying on the working directory:
#!/bin/sh
# entwine2tiles-docker, chmod +x, then:
# export ENTWINE2TILES_EXE=/path/to/entwine2tiles-docker
exec docker run --rm \
-v /data:/data \
-u "$(id -u):$(id -g)" \
ghcr.io/arkounm/entwine2tiles:latest "$@"Keep your scans and output under the mounted root. -u keeps the tiles owned
by you rather than by root. Installing through conda avoids all of this, which
is why it is listed first.
Anything the engine's PDAL can read, including .las, .laz, .e57, .ply,
.pcd, .pts, .ptx, and .bpf. The pipeline does not gatekeep formats, it
passes the file to the engine and lets PDAL decide.
E57 needs an entwine2tiles built with PDAL's E57 reader, which on conda is
the libpdal-e57 package. Without it the engine says so plainly. E57 no longer
needs LAStools.
Formats with no bounds in their header (E57, PLY, PCD, PTS, PTX) get an extra analysis pass over every point, since otherwise the octree would be sized from nothing. That is automatic.
The pipeline needs one executable. It resolves in this order:
ENTWINE2TILES_EXEenvironment variable- Auto-detection via
PATH - The hardcoded default in
config.py
ENTWINE2TILES_EXE = r"...\entwine2tiles\build\app\entwine2tiles.exe"
# Only if the binary was built inside a conda environment and needs its
# libraries on PATH. Leave empty if entwine2tiles runs standalone.
ENTWINE2TILES_LIB_DIRS = [...]
ENTWINE_THREADS = 16
GEOMETRIC_ERROR_DIVISOR = 16.0
ROOT_ERROR_MULTIPLIER = 16.0
# Geolocation defaults (Ottawa; see the warning below)
DEFAULT_LONGITUDE = "-75.703833"
DEFAULT_LATITUDE = "45.417139"
DEFAULT_HEIGHT = "77.572"Validate with:
python config.pyDEFAULT_LONGITUDE/DEFAULT_LATITUDE/DEFAULT_HEIGHT are the author's own
Ottawa coordinates, a fallback so the pipeline does not hard-fail if you forget
the flags. Without them the pipeline prints a loud warning and proceeds. The
tileset will load and render fine, it will just be geographically wrong. Pass
real coordinates for real data.
# Single file
python main.py -i input.laz -o output/
python main.py -i scan001.e57 -o output/
# A directory, sequentially, mixed formats allowed
python main.py -i scans/ -o output/
# A directory, in parallel
python main.py -i scans/ -o output/ --batch-workers 4
# Real coordinates, no compression
python main.py -i input.laz -o output/ \
--longitude -75.7 --latitude 45.4 --height 100 --no-gzip
# Force colour off, or force it from intensity
python main.py -i input.laz -o output/ --color-type none
python main.py -i input.laz -o output/ --color-type intensity
# Keep the octree for inspection
python main.py -i input.laz -o output/ --tempIf a scan has been converted before and the level is already built around where it sits, copy the old placement rather than rediscovering it:
python scripts/geolocate_tileset.py -i output/scan001 \
--match old_output/scan001/tileset.jsonThat carries over whatever the reference tileset had, including any heading rotation, and adjusts for the fact that this pipeline writes content relative to the centre of the dataset while older ones wrote raw scan coordinates. Identical source coordinates end up in identical world positions.
| Argument | Description | Default |
|---|---|---|
-i, --input |
Input point cloud, or a directory of them (required) | - |
-o, --output |
Output directory for 3D tiles (required) | - |
-t, --threads |
Thread count for both stages | 16 |
-f, --force |
Rebuild the octree if it exists | False |
--batch-workers |
Files processed simultaneously, sharing --threads between them |
1 (sequential) |
--deep / --no-deep |
Read every point to find the bounds | automatic by format |
--longitude |
Longitude in degrees | -75.703833 (warns if left at default) |
--latitude |
Latitude in degrees | 45.417139 (warns if left at default) |
--height |
Height in metres | 77.572 (warns if left at default) |
--gzip / --no-gzip |
Compress the output | --gzip |
--temp |
Keep the octree | False |
--root-multiplier |
Root tile visibility distance multiplier | 16.0 |
--error-divisor |
Root geometric error is the cube width over this | 16.0 |
--color-type |
none, rgb, intensity, tile |
chosen from the data |
Both default to 16.0, which reads like duplication and is not. They are
passed straight to the engine and do different jobs:
--error-divisorsets the whole error curve. The root error is the width of the dataset cube over this value, and every level halves from there.16reproduces the curve the pre-v2 pipeline arrived at by hand, which is why the two numbers happen to match.--root-multiplierinflates the root node only, leaving the rest of the curve alone. It exists for spawn distance, not for density.
Change --error-divisor if the whole tileset loads at the wrong density.
Change --root-multiplier if only the root tile misbehaves at range.
The hierarchy itself comes from the engine. Earlier versions of this pipeline
generated a flat tileset with 3d-tiles-tools and then restructured it into an
octree in Python, computing geometric errors per tile; none of that code
survives. One consequence is visible to the validator: the Python restructurer
left 207 empty children: [] arrays, and the engine's output has none.
By default the engine uses RGB if the file has it, greyscale intensity if not,
and no colour if the file has neither. LAS point format 1 files carry intensity
but no RGB, so they come out greyscale. Override with --color-type.
Colour is written in linear space, which is what glTF requires, converted from the sRGB that scanners store. Skipping that conversion makes a tileset render noticeably brighter and flatter than the same scan converted elsewhere. Whether a file's 16-bit channels actually use the high byte is read from the data rather than guessed.
pointcloud2Tiles/
├── main.py # Orchestration
├── config.py # Configuration
├── README.md
├── CHANGELOG.md
├── LICENSE # Apache-2.0
└── scripts/
├── entwine2tiles.py # Steps 1 and 2: octree, then tiles
├── geolocate_tileset.py # Step 3: place it on the globe
└── compress.py # Step 4: gzip
output/
└── scan001/
├── tileset.json
├── 0-0-0-0.glb
├── 1-0-0-0.glb
└── ...
Each input file gets its own subdirectory named after it, single file or batch.
The octree is written to output/<name>/temp/ept and deleted after step 2
unless --temp is passed.
Tiles are 3D Tiles 1.1 with glTF content. Positions are written relative to the
centre of the dataset and the root transform places that centre on the globe,
which is what keeps 32-bit float coordinates precise. The dataset origin in
source coordinates is recorded in asset.extras.entwine2tiles.origin.
When you spawn far from a point cloud (5k to 10k Unreal units), the root tile
can disappear because its geometric error is too small for the camera distance.
--root-multiplier scales the error of the root node only, so it stays loaded
from farther away without affecting how children refine.
- 8.0: close range only
- 16.0 (default): most cases
- 24.0: spawning 5k to 10k units away
- 32.0: 10k+ units
The octree build dominates, and by a wider margin than it looks. Measured on one machine, 16 threads:
| Scan | Points | Octree | Tiles | Octree share |
|---|---|---|---|---|
3675028.las |
13,222,984 | 17.05s | 1.28s | 91% |
PA_UCS_NOROOF |
266,055,966 | 536.97s | 48.84s | 91% |
That is roughly 500k to 780k points per second for the octree and 5 to 10 million for the tiles. The tile writer is about 11x faster than the thing feeding it, so optimising it is not where the time is.
--batch-workers processes several files at once, and it is worth using. The
thread budget is split between workers, not multiplied: --batch-workers 2 --threads 16 runs two engine processes with 8 threads each. Measured on five
files, 2.2 GB:
| Time | |
|---|---|
| 1 worker x 16 threads | 133.09s |
| 2 workers x 8 threads | 105.35s |
The same 16 threads, 21% faster, because the octree build has serial phases where threads sit idle and a second file fills them.
Disk: the octree is roughly the size of the input, and the tiles run about 19 bytes per point with colour, 12 without. Both exist at once until step 2 finishes.
"entwine2tiles not found"
Set ENTWINE2TILES_EXE, or put the binary on PATH. Test with
entwine2tiles help. There is no --version flag: it falls through to
"Invalid app type", so help is the check that actually exercises the binary.
"Could not run entwine2tiles"
The binary was found but could not load. If it was built inside a conda
environment, its libraries need to be on PATH: see ENTWINE2TILES_LIB_DIRS
in config.py, or activate that environment before running the pipeline.
E57 input fails with a reader error
The engine was built without PDAL's E57 reader. Install libpdal-e57 into its
environment and rebuild, or convert to LAZ first.
"The octree is empty: no point of ... landed inside the bounds"
The analysis pass sized the octree from bounds the file declares but does not
honour, so every real point fell outside it. Re-run with --deep, which reads
every point instead of trusting the header. This is automatic for the formats
known to need it, so seeing this means your file needs it and its extension is
not on that list. The build before this check existed exited 0 with an empty
output, which is why the check is there.
Tiles not loading in a viewer
Check the coordinates match your data. A tileset can load successfully and
still be in the wrong place. If your viewer wants uncompressed tiles, use
--no-gzip.
Batch processing fails partway through
The final summary lists which files failed. Run those individually to see the
error, and reduce --batch-workers if it looks like resource exhaustion.
- Entwine by Connor Manning and Hobu, Inc., which does the actual spatial work, and whose 2019 Cesium writer the fork restores
- mesh2Tile, the photogrammetry sibling of this pipeline
Apache License 2.0. See LICENSE for the full text.
entwine2tiles is a separate repository under LGPL-2.1, because Entwine is.
This pipeline invokes it as an executable and does not link against it.