A Godot 4.3+ GDExtension for loading GoldSrc (Half-Life 1) engine assets: BSP maps, MDL models, SPR sprites, and WAD texture archives.
- Full BSP30 format with face-based mesh generation
- Atlas-packed lightmaps with 64 lightstyle channels and runtime rebaking
- Embedded and WAD-referenced textures with transparency (
{prefix alpha-scissor) - Animated textures (
+0name…+9name/+aname…+jnamealternates) — frames collected at import and stored astex_anim_framesmetadata on eachMeshInstance3D; driven at 10 FPS byTextureAnimatorat runtime with no C++ dependency - Water/liquid textures (
!and*prefix) rendered with a turbulent UV-warp shader (sine-wave distortion animated viaTIME) - Hull 0 collision (StaticBody3D for worldspawn, AnimatableBody3D for brush entities)
- Water volume extraction as Area3D with ConvexPolygonShape3D
- Automatic occluder generation (OccluderInstance3D + PolygonOccluder3D) — see Occluder Generation below
- PVS (Potentially Visible Set) data parsing with RLE decompression — used by
debug_occludersmode to validate occluder effectiveness against the BSP's precomputed visibility data - Worldspawn spatial splitting — walks the BSP tree to group faces into spatial clusters, producing separate MeshInstance3D nodes per group for better frustum culling
- Brush entity geometry wrapped in AnimatableBody3D ("Body") with meshes and collision inside, ready for GDScript movement without body conversion
- Point entity nodes (Node3D) with entity properties stored as metadata — classname, targetname, origin, angles, and all other key-value pairs are accessible from GDScript via
node.get_meta("entity") - Entity lump parsing (key-value dictionaries accessible from GDScript)
- Ambient cube light grid baking — traces rays in 6 directions from a 3D grid through the BSP tree, samples lightmaps at hit points, and outputs slice images for
ImageTexture3Dconstruction. Includes flood-fill of solid cells to prevent trilinear interpolation artifacts. Provides spatially-varying directional ambient lighting for dynamic models
- Skeleton3D with full bone hierarchy
- Skinned meshes with per-vertex bone weights
- All animation sequences as AnimationPlayer tracks
- Chrome and additive material flags
- Configurable scale factor
- All frame types (single and grouped)
- Texture formats: normal, additive, index-alpha, alpha-test
- Sprite types: parallel, facing-upright, oriented, etc.
- Frame textures and origins accessible individually from GDScript
build_scene()produces a self-animating Sprite3D with aSpriteAnimationPlayerchild (GDScript node, no C++ dependency at runtime) — exported properties:fps(default 10.0),loop_animation(default true),autoplay(default "default"); non-looping animations auto-hide the sprite on completion
- WAD2/WAD3 format support
- Palette-based to RGBA conversion with auto-generated mipmaps
- Case-insensitive texture lookup
- Per-texture caching
Drop files into a project and they auto-import:
| Format | Extension | Output | Description |
|---|---|---|---|
| BSP | .bsp |
.scn |
PackedScene with meshes, lightmaps, collision |
| MDL | .mdl |
.scn |
PackedScene with Skeleton3D, meshes, animations |
| SPR | .spr |
.scn |
PackedScene with self-animating Sprite3D and SpriteAnimationPlayer child |
| WAD | .wad |
.png files |
Extracts individual textures as PNGs |
All imported scenes contain only standard Godot types (Node3D, MeshInstance3D, ArrayMesh, Skeleton3D, AnimationPlayer, StaticBody3D, AnimatableBody3D, OccluderInstance3D, etc.) and do not require the GDExtension at runtime.
The BSP importer automatically instantiates MDL/SPR scenes for point entities (monsters, props, sprites, etc.). Entity model keys are resolved as res:// + the entity path, mirroring the GoldSrc layout at the project root — so models/headcrab.mdl maps to res://models/headcrab.mdl.
If the asset is not found, a small placeholder BoxMesh (0.3 m³) is placed at the entity position so it remains visible in the editor.
Typical workflow:
- Drop your game's
models/andsprites/folders into the project root (res://). - Godot auto-imports all
.mdland.sprfiles. - Import your
.bsp— entities with models/sprites appear in the scene automatically.
Convert BSP maps from the command line without opening the editor:
godot --path <project-dir> --script res://tools/batch_convert_bsp.gd -- \
--bsp map1.bsp --bsp map2.bsp \
--wad-dir /path/to/wads \
--output-dir /path/to/output \
--scale 0.025 \
--shader-lightstyles \
--overbright 2.0 \
--rotateOptions:
--bsp— input BSP file (repeat for multiple maps)--wad-dir— directory containing.wadfiles for texture lookup--output-dir— where to write.scnfiles--scale— coordinate scale factor (default:0.025)--shader-lightstyles— use shader-based lightstyle animation--overbright— lightmap brightness multiplier (default:1.0)--rotate— rotate 180 degrees around Y to match alternate coordinate conventions
Outputs a .scn PackedScene file per map with all geometry, collision, occluders, and entity nodes baked in.
The importer automatically generates OccluderInstance3D + PolygonOccluder3D nodes for worldspawn wall geometry. To use them at runtime, enable Project Settings > Rendering > Occlusion Culling > Use Occlusion Culling.
- Face collection — worldspawn wall faces are gathered. Sky, water, transparent, and tool textures are excluded. Faces whose normal aligns with
occluder_exclude_normalbeyondoccluder_exclude_thresholdare excluded (default: horizontal faces such as floors and ceilings; set threshold to0to disable). - Coplanar grouping — faces are grouped by quantized plane key (normal + distance) to find walls that share a plane.
- Connected components — within each plane group, union-find on shared vertices identifies contiguous face patches.
- Boundary edge merging — shared interior edges cancel out, leaving only the true outer boundary of each patch (plus any interior holes from doorways/windows).
- Loop classification:
- Single loop → solid wall, one merged occluder.
- Multiple loops, holes BSP-solid → the "holes" are backed by solid geometry (e.g. recessed detail), so one merged occluder from the outer loop is safe.
- Multiple loops, real openings → doorways/windows detected via BSP tree traversal. The algorithm re-runs edge cancellation on only the qualifying faces (area ≥
occluder_min_area). Adjacent solid panels merge into one occluder; the doorway spaces become the natural exterior boundary rather than interior holes.
- Boundary filtering — faces whose plane sits within
occluder_boundary_marginGoldSrc units of the worldspawn bbox are skipped. Players can never be on both sides of an outer-hull face, so it provides no occlusion value. - Importance sorting and capping — all candidate occluders are sorted by polygon area (largest first). If
occluder_max_countis non-zero, only the top N are kept. This provides a performance budget while ensuring the most impactful occluders are always retained. - Polygon cleanup — duplicate and collinear vertices are removed, and each polygon is pre-validated against Godot's triangulator before being committed as an occluder.
| Parameter | Default | Description |
|---|---|---|
occluder_min_area |
65535 | Minimum face area in GoldSrc units² for a face to qualify as an occluder. ~256×256 at default. Raise to get fewer, larger occluders; lower to include smaller walls. |
occluder_boundary_margin |
512 | Faces within this many GoldSrc units of the map's bounding box boundary are skipped. Outer-hull faces provide no occlusion value since the player is never on both sides of them. |
occluder_exclude_normal |
Vector3(0, 1, 0) |
Faces whose normal aligns with this axis (in Godot space) beyond occluder_exclude_threshold are excluded. Default excludes horizontal faces (floors/ceilings). For vertical levels, set this to the axis that is "horizontal" in that level. |
occluder_exclude_threshold |
0.7 | Alignment threshold for occluder_exclude_normal: faces where abs(dot(face_normal, exclude_normal)) > threshold are excluded. Set to 0 to disable normal-based exclusion entirely. |
occluder_max_count |
0 | Maximum number of occluders to generate. Candidates are sorted by area (largest first) and the top N are kept. 0 = unlimited. Useful for bounding culling overhead on complex maps. |
These can be set per-map in the Godot Import tab or directly in the .bsp.import file:
[params]
occluder_min_area=65535.0
occluder_boundary_margin=512.0
occluder_exclude_normal=Vector3(0, 1, 0)
occluder_exclude_threshold=0.7
occluder_max_count=0Set debug_occluders = true on the GoldSrcBSP node before calling build_mesh() to print a full pipeline report including: face counts, component breakdown by type (solid/solid-holes/real-openings/walk-failures), occluder coverage percentage, overfill checks on merged polygons, and PVS validation (what fraction of BSP-invisible leaf pairs have an occluder plane between them).
Requires CMake 3.22+ and a C++17 compiler.
git clone --recursive https://github.com/alanfischer/goldsrc-godot.git
cd goldsrc-godot
mkdir build && cd build
cmake ..
make -j8The compiled library goes to addons/goldsrc/bin/. Open the project in Godot and enable the GoldSrc plugin under Project Settings > Plugins.
var bsp = GoldSrcBSP.new()
bsp.scale_factor = 0.025
var wad = GoldSrcWAD.new()
wad.load_wad("textures.wad")
bsp.add_wad(wad)
bsp.load_bsp("map.bsp")
bsp.build_mesh()
# Entity data is on the child nodes as metadata:
for child in bsp.get_children():
if child.has_meta("entity"):
var ent = child.get_meta("entity") # Dictionary
print(ent.get("classname", ""))
print(ent.get("targetname", ""))
# Or get raw entity dictionaries:
var entities = bsp.get_entities() # Array of Dictionaries
# Optional: tune occluder generation (before build_mesh)
bsp.occluder_min_area = 65535.0 # min face area in GoldSrc units²
bsp.occluder_boundary_margin = 512.0 # skip faces near outer map hull
bsp.occluder_exclude_normal = Vector3(0,1,0) # Godot-space axis to exclude (default: Y-up = horizontal)
bsp.occluder_exclude_threshold = 0.7 # exclusion strength; 0 = disabled
bsp.occluder_max_count = 0 # cap on occluder count (0 = unlimited); sorted by area
bsp.debug_occluders = true # prints PVS validation, overfill checks, pipeline stats
# Bake ambient cube light grid (call after build_mesh)
var grid = bsp.bake_light_grid(32.0) # cell size in GoldSrc units
# Returns Dictionary with:
# grid_origin: Vector3 — world-space origin in Godot coords
# grid_dims: Vector3i — grid dimensions in Godot coords (X, Y, Z)
# cell_size: float — cell size in Godot units
# dir_slices: Array of 6 Arrays of Images — one per axis direction
# (+X, -X, +Y, -Y, +Z, -Z), each array contains depth-slice Images
# for ImageTexture3D constructionvar mdl = GoldSrcMDL.new()
mdl.scale_factor = 0.025
mdl.load_mdl("model.mdl")
mdl.build_model()
print(mdl.get_sequence_count()) # Number of animations
print(mdl.get_sequence_name(0)) # First animation name
print(mdl.get_bone_count()) # Number of bonesvar spr = GoldSrcSPR.new()
spr.load_spr("sprite.spr")
var frame_count = spr.get_frame_count()
var texture = spr.get_frame_texture(0) # ImageTexture
var origin = spr.get_frame_origin(0) # Vector2i — up/left extent from entity position
var spr_type = spr.get_type() # SPR_VP_PARALLEL, etc.
# Build a self-animating Sprite3D scene (no C++ dependency at runtime):
var sprite = spr.build_scene() # Sprite3D with SpriteAnimationPlayer child
# Override animation properties before adding to tree:
var sap = sprite.get_node("SpriteAnimationPlayer")
sap.fps = 20.0
sap.loop_animation = false
# Frames and origins are stored as metadata on the Sprite3D:
# sprite.get_meta("tex_anim_frames") # Array[ImageTexture]
# sprite.get_meta("tex_anim_origins") # Array of [ox, oy] pairsvar wad = GoldSrcWAD.new()
wad.load_wad("textures.wad")
var names = wad.get_texture_names() # PackedStringArray
var tex = wad.get_texture("concrete1") # ImageTextureGoldSrc uses Z-up; Godot uses Y-up. The plugin converts automatically:
- Positions:
(x, y, z)→(-x * scale, z * scale, y * scale) - Quaternions: conjugation by -90° X rotation
Default scale factor is 0.025 (1 GoldSrc unit = 0.025 Godot units).
