Plan for loading Iris shader packs¶
The aim is to load an Iris or OptiFine shader pack and have it render, not to be a drop in replacement for Iris. Drop in compatibility means implementing Minecraft's renderer, and that is not a compatibility layer, it is a second renderer. What follows is the line between the two, and it is a real line rather than a matter of effort.
What 22 popular packs actually ask for, and the order of work that follows from it, is in the survey of popular shader packs.
Status, 2026-10-11¶
Rungs 1 and 3 below are done for the screen space chain, and rung 4 runs
every program a pack enables. What has been observed live is still only the
proof pack, on 2026-08-21 and 2026-10-10; real packs have so far been
loaded, translated and compiled offline, not run. Per pack results are in
docs/develop/shaderpack-status.md.
What is new since 2026-08-21, all in src/iris/ and checked by
goanna_iris_pack_test and the offline compile check:
shaders.propertiesgoes through a preprocessor first (iris_preproc.cpp, the one#ifengine the loader, the chain and the gbuffers translator share since later the same day):#if,#ifdef,#elif,#defineand the rest, against the MC_ and IRIS_ macros and the pack's option defaults, which the loader gathers from the#definelines of its sources.program.<name>.enabled(and theworld0/form) switches chain programs off. Option defaults can be overridden withGOANNA_SHADERPACK_OPTIONS, which rewrites the sources'#definelines as an Iris options file would.uniform.<type>.<name>andvariable.<type>.<name>are parsed into an evaluator with OptiFine's function set (if,smooth,in,between,frac,vec3and so on, matrix elements asm.1.2), evaluated every frame against the standard block, and written into each pass's extra uniform block. A custom uniform named like a standard one replaces it.- Each program's own conditionals are resolved per stage before
translation, so a declaration in a pack's
#ifdef FSHbranch no longer collides with the vertex stage's. iris.features.requiredis read; Goanna declares no optional Iris feature, so a pack that requires one (Noble, Rethinking Voxels, Shrimple) is refused with the reason inreport().- Depth reaches the pack in OpenGL's convention. Godot 4.5 stores reversed
Z, so the effect copies the depth buffer inverted into the texture a pack
reads; before this a pack's sky test (
depth == 1.0) and every position reconstruction were inside out.depthtex1is a copy taken atPRE_TRANSPARENTby a companion effect (make_depth_capture), so depth writing translucents such as glass are indepthtex0only. Water writes no depth in Goanna, so it is in neither, which a pack's water detection will notice.depthtex2isdepthtex1: the hand is drawn with the opaque pass.centerDepthSmoothis the centre pixel ofdepthtex0, read back asynchronously and smoothed with the pack'scenterDepthHalflife. The readback's callback arrives on the main thread a frame or two later and may outlive the effect, so it holds a small shared state, never the effect itself. Bound to the effect, it crashed a client that freed the effect with a readback in flight (two core dumps on 2026-10-11, reproduced on 2026-10-12 with Godot 4.5.1 on the RTX 3090).tests/iris_teardown.tscnfrees the effect at that moment, throughtools/goanna-headless fixture; it needs the GPU, because lavapipe crashes earlier, clearing one of the pack's colour targets. texture.noiseandtexture.deferred.*andtexture.composite.*image files are loaded and bound in place of the named sampler; raw (.dat) and resource textures are reported and skipped.sampler2DShadowgets a comparison sampler, over the pack's own shadow map since 2026-10-12 (rung 7, "The shadow map, as built"), and until it is drawn over a cleared 1x1 depth texture that says lit.- The translator fixes the real packs needed are listed in the status page.
All of the effect side is in commit a4410a9c. Its first GPU run came
later on 2026-10-11, as a side effect of the gbuffers frames (see "The
gbuffers translator, as built" below): Eclipse's chain (3 of 3 passes) and
Mellow's (9 of 9) compiled and ran on the RTX 3090 against the render
service's fixture world (Luanti 5.17.0, Mineclonia, Godot 4.5.1), and both
clients then crashed with signal 11 in a GoannaIrisEffect callable taking
a PackedByteArray, which reads as the centre depth readback arriving
after the effect is gone; the frames had already been taken. How the
chain looks was not judged.
On 2026-10-13 a probe pack (a final that paints near, far and the
distance rebuilt from depthtex0) showed that until commit 0fce1c1b the
chain's projection was wrong for every pack: RenderSceneData hands a
compositor effect the projection Godot renders with, its depth already in
Vulkan's reversed [0, 1], and near and far read off it came out as
-0.05. The effect now undoes the z row to OpenGL's form (the y flip stays,
because the chain's texture coordinates run top down). That was the whole
of Mellow's pale haze, and part of Eclipse's darkness; see "What was
observed" below.
The rest of this section is the state of 2026-08-21, kept because it is still true unless a line above says otherwise.
On 2026-08-21 rung 1 was done and rung 4's machinery in place, measured
rather than claimed. src/iris/ holds a pack loader (iris_pack.cpp), the GLSL translator
(iris_glsl.cpp) and GoannaIrisEffect, a CompositorEffect that runs a
pack's deferred*, composite* and final programs at POST_TRANSPARENT.
GOANNA_SHADERPACK=/path/to/pack loads one from main.gd. Against a local
Mineclonia server on Luanti 5.16.1, Godot 4.5.1 Forward+ on Vulkan (NVIDIA), the
proof pack in project/tests/shaderpacks/proof/ renders: a composite writing
two targets through /* DRAWBUFFERS:12 */, and a final reading them back
through the ping pong, plus depthtex0, noisetex and live uniforms. The check
is tools/test/test-shaderpack.sh; see docs/develop/shaderpack-testing.md.
What that proves: GLSL 120 dialect packs translate and compile through
shader_compile_spirv_from_source; colortex targets live in the scene
buffers as named textures and resize with them; the uniform block and
sampler bindings are right; the chain order, DRAWBUFFERS mapping, clearing
and ping pong behave. What it does not prove: any real pack. The proof pack
is ours, written to exercise the dialect, and the long tail of pack variety
is untested.
Things learnt on the way, recorded so they are not relearnt:
gl_FragData[n]is the n-th entry of the DRAWBUFFERS list, not colortex n. WithDRAWBUFFERS:12,gl_FragData[0]is colortex1. The first draft had this backwards and Godot refused the pipeline with an output mask mismatch, which is at least a loud failure.- Vulkan GLSL wants every varying located and every sampler bound, and bare
uniform float x;is not allowed. So the pack's declarations are removed and re-emitted: one std140 block for the Iris uniforms, with a second per program for uniforms we do not know (they read zero, and are listed in the effect'sreport()), samplers declared only where a stage references them, varyings located by sorted name across both stages. - std140 rounds the block size to 16 but members only to their own alignment. Rounding after every member silently moved every scalar.
- Colour space. Godot's colour buffer is linear HDR and is tonemapped after
the effect; Minecraft's is gamma space LDR and a pack's
finalis the finished image. The effect encodes on the way intocolortex0and decodesfinal's output back to linear, andmain.gdsets a linear tonemap while a pack ownsfinal.GOANNA_SHADERPACK_RAW=1turns the bridge off for comparison. Without it a pack's result is tonemapped twice. - Texture coordinates are not flipped: Vulkan's top left origin agrees with
image row order, so an identity pass is an identity pass.
gl_FragCoord.yruns the other way from GL, which a pack using it for anything other than a texture lookup will notice. Untested so far. - The uniform set (rung 2) is partly live, 2026-08-21. From the session:
the matrices, sun and moon,
sunAngleandworldTime,skyColor(the server's day, dawn and night sky blended asmain.gdblends it) andfogColor(the environment's fog colour, so the underwater tint while submerged),rainStrengthandwetness(Luanti has no weather in the protocol; a rain or snow particle spawner attached to the player, which is how Mineclonia rains, reads as 1.0, and the effect ramps the two the way Minecraft and Iris do: 0.2 a second, then half lives of 30 s wetting and 10 s drying),isEyeInWater(1 in any liquid, never 2: the node definition does not say which liquids are lava),eyeAltitude,near,far. Still constant on 2026-10-11:eyeBrightnessandeyeBrightnessSmooth(240, 240; the client does not expose node light at a position yet),moonPhaseandworldDay(0; the protocol carries no day count), health, hunger, air,nightVision,blindness,atlasSize,fogStart,fogEnd,biome. In custom uniform expressionsbiome_precipitationreads as rain and the OptiFine player flags (is_on_groundand the rest) read false. - Not yet done: compute (
.csh),prepare,begin,setupandshadowcompprograms, custom images and SSBOs, resource custom textures (minecraft:...), thescaleandflipdirectives and multiview. Done on 2026-10-13, for Photon: - Varyings of struct type (its
clouds_params) are left where the pack declared them, after their struct, and located there with a location per member. size.buffer.colortexNsizes the buffer: a fraction of the screen when written with a decimal point (0.5 0.5), else pixels (192 108). A pass drawing into it covers it whole, as Iris sets the viewport to the target.- Raw custom textures (
scattering.dat TEXTURE_3D RGB16F 32 64 32 RGB HALF_FLOAT) are read as 1D, 2D or 3D textures in the layout the line gives, three components widened to four. A custom texture binds only to a sampler of its own dimension, so Photon's 3D noise namedcolortex0in the composite stage leaves itssampler2D colortex0reading the scene, and a trailing.1on the name is dropped. - A legacy sampler alias (
shadow,gaux1) is renamed only where the pack declares a sampler by it; Photon's localfloat shadowhad been turned into a secondshadowtex0. - A
#ifcontinued over lines with a backslash is read whole by the loader's conditional pass.
The gbuffers translator (rungs 5 to 7) is built since 2026-10-11 and the pack's own shadow map since 2026-10-12; see "The gbuffers translator, as built".
GOANNA_SHADERPACK_DUMP=<dir> writes each pass's translated GLSL, which is
the first thing to look at when a pack fails to compile: the error line
numbers refer to the pack's own file because the translator keeps lines.
Changed on 2026-08-22: the default pack will be a real one rather than one
of ours. Base-Shader first, Eclipse after it. Both are licensed to ship, the
lightmap coordinate a gbuffers pack needs turned out to be carried already,
and Base asks for little else, so the translator rungs move ahead of writing
a pack. See "The first target, and why it is Base-Shader" below, and task 5
in docs/develop/launch-target.md.
What Godot gives us, checked rather than assumed¶
Two APIs decide whether this is possible at all, and both exist in 4.5.
CompositorEffect injects work into the scene render at five points:
PRE_OPAQUE, POST_OPAQUE, POST_SKY, PRE_TRANSPARENT,
POST_TRANSPARENT, through _render_callback(type, RenderData *).
RenderingDevice::shader_compile_spirv_from_source compiles GLSL to SPIR-V
at runtime, so a pack's .vsh/.fsh/.csh can be compiled after
preprocessing without going anywhere near .gdshader. That is the whole
reason this approach beats translating to Godot's shader language: we
recreate the environment the GLSL expects instead of rewriting the GLSL.
RenderSceneBuffersRD is better than expected. Alongside
get_color_texture(), get_depth_texture() and get_velocity_texture() it
has create_texture(context, name, format, ...) and get_texture(context,
name), which means colortex0 through colortex15, the shadow maps and the
ping pong buffers can be allocated as named textures in Godot's own render
buffer pool, with its lifetime and resizing handling, rather than managed by
hand.
Where the line falls¶
An Iris pack is not one kind of program. It is several, and they divide cleanly on whether they draw geometry.
The screen space chain: deferred1..N, composite1..N, final. These
read colortex* and depthtex* and write colortex*. They are full screen
passes. They map onto CompositorEffect almost directly, and they are where
most of what people mean by "a shader pack" lives: volumetric light, screen
space reflection, ambient occlusion, colour grading, bloom, tonemapping, the
sky. This part is tractable.
The geometry programs: gbuffers_* and shadow. These do not
post process anything. They are how the world is drawn: Iris swaps them in
for Minecraft's own geometry rendering. There is no equivalent hook in
Godot, because CompositorEffect runs around the scene render, not instead
of it.
That is the line. Everything on the first side is a compatibility layer. Anything that requires the second side is writing a renderer.
There is a further wrinkle behind it. Iris packs assume Minecraft's deferred
G buffer: colortex0 is albedo, colortex1 tends to be normals, and so on
by convention. Godot's Forward+ is a clustered forward renderer and has no
such G buffer. What it hands us is an already lit colour buffer. So even
the screen space chain is being fed something different in kind from what it
expects.
The shape that resolves it¶
The way through is not one mechanism but two, chosen per program class:
IRIS PACK
|
+---------------+---------------+
v v v
gbuffers_terrain gbuffers_water shadow
| | |
v v v
Godot terrain Godot water Godot shadow
SpatialShader SpatialShader pass
vertex() vertex()
fragment() fragment()
light() light()
|
v
Godot Forward+
|
colour / depth / normal
|
v
deferred / composite / final
|
v
CompositorEffects
The geometry programs are translated into Godot spatial shaders, where
vertex(), fragment() and light() already have the shape a gbuffers
program has, and Godot's own forward and shadow passes then run them. The
screen space chain is not translated: it is compiled as GLSL through
shader_compile_spirv_from_source and run as CompositorEffects, in the
environment Iris supplies. Each mechanism is used where it fits, and the
"there is no hook to replace geometry rendering" objection goes away, because
nothing is being replaced.
Translated gbuffers must be unshaded¶
The subtlety that decides whether this works at all. An Iris gbuffers program
does not light anything: it writes G buffer data, and lighting happens later
in deferred. A Godot spatial shader, left alone, is lit by Godot. Translate
one naively and the surface is lit twice, once by Godot's model and once by
the pack's.
So a translated gbuffers program takes render_mode unshaded. Godot then
leaves the fragment alone, the colour buffer holds what the program wrote
rather than a lit result, and it behaves like a G buffer. This also answers
the forward versus deferred mismatch noted above, for the geometry the pack
actually supplies a program for: it is not that Godot's renderer is made
deferred, it is that the surfaces the pack owns stop being forward lit.
Two translations, not one¶
Worth separating, because they have different shapes, different risks and different amounts of prior art:
1. API translation. Iris uses its G buffer as an API between pack
authored geometry and pack authored postprocessing: the pack decides what
colortex1 means and both halves agree. Godot has a fixed API between a
spatial shader and Forward+: ALBEDO, NORMAL, ROUGHNESS and the rest,
with meanings the engine picks. Translating one API onto the other is what
makes a surprising amount survive, and it is a semantic mapping job rather
than a compiler one. The hard cases are packs using colortex* or
shadowcolor* as arbitrary intermediate data with no corresponding material
concept, which is where extra targets or a RenderingDevice pass are the
answer; that low level API exists precisely for work outside the renderer's
abstraction.
2. Language translation. Compatibility profile GLSL into Godot's shader
language. Godot's own porting guide is clear this is not trivial: separate
programs merge into one file, main becomes vertex() and fragment(),
declarations and outputs work differently. Expect most of a pack's body to be
ordinary GLSL expressions and functions, with the work concentrated in legacy
syntax, Iris substitutions and semantic rewrites rather than spread evenly.
The second is much smaller than it first looks, because the target is
known. This is not general GLSL to Godot translation. It is
gbuffers_terrain into one specific spatial material, gbuffers_water into
another, against a shell we write by hand: a .gdshader that already
declares the Iris uniform and varying environment and provides its helper
functions, into which the pack's main bodies are placed. That reduces the
translator to expression level work inside a fixed harness, which is
glslang style parsing, the inverse of Godot's lowering rules, and a few dozen
Iris specific rewrites. Write the shell first and the translator's job
becomes obvious.
Reference material, and which version of it¶
servers/rendering/shader_types.cpp in godotengine/godot is the authoritative
table of what each shader type's functions may read and write, and
servers/rendering/shader_compiler.cpp is the lowering that has to be
inverted. Both MIT, both current, and both readable without licence concern.
dfranx/GodotShaderTranscompiler is the same idea packaged, and is MIT, but it is Godot 3.x GLES3 extracted verbatim in 2019. Godot 4's spatial shader differs substantially: Vulkan, different built ins, different render modes. Use it to see the shape of the problem; take the tables from current upstream.
Unshaded is not a decision, and light() is always unused¶
render_mode unshaded skips Godot's lighting entirely, so light() never
runs in a translated gbuffers program. That is not a trade off to weigh per
program: it is always right, because a gbuffers program never wants engine
lighting in either shape a pack can take.
- The pack lights inline in the gbuffers fragment, sampling the lightmap
through
lmcoord, and outputs a lit pixel. Godot lighting it again is double lighting. - The pack writes raw G buffer data and lights in
deferred. Godot lighting it corruptscolortex0before the pack ever sees it.
Godot's lighting model is the wrong one either way, so it goes off, and the
light() box in the diagram above is never reached.
What is worth inferring is a different question with a trivial answer: does
the pack ship deferred* programs? That tells you where lighting happens,
which decides whether colortex0 after the geometry pass holds lit colour or
raw albedo, and therefore what the composite chain is entitled to assume. It
is a directory listing, not an analysis.
What lmcoord needs from Goanna, which is now carried¶
Both cases above have a dependency that is easy to miss. A pack reads
Minecraft's baked lightmap through lmcoord: block light in one axis, sky
light in the other. A pack that lights inline reads it in the gbuffers
fragment. A pack that lights in deferred reads it in gbuffers all the same
and writes it to a colortex for the lighting pass to use, because the
lightmap is the only source of torch and lava light either kind has. So this
is not a subset of packs; nearly every pack reads it somewhere. That is the
same data Luanti carries as per vertex light on mapblock meshes.
This was a hole until 2026-08-21, when per vertex light landed for its own
reasons. CUSTOM0 now carries Luanti's LIGHTBANK_NIGHT in r and
LIGHTBANK_DAY in g on every node surface, near mesh and far tier alike;
docs/systems/mesh-attributes.md is the contract. So lmcoord is a scale of
what is already on the vertex, CUSTOM0.rg * 255.0, rather than a path to
revive. Earlier drafts of this page said otherwise and were right when they were
written.
The lightmap sampler itself, the small texture a pack multiplies that
coordinate through, is generated since 2026-10-11 by GoannaIrisGbuffers
each frame: sixteen by sixteen, block light against sky light, white at
noon under full sky as Minecraft's is, a dim blue at night, a warm torch
colour for block light. CUSTOM0 holds brightness rather than a light
level, so the coordinate is CUSTOM0.rg * 240.0, Minecraft's own range,
and the table is linear in it rather than carrying a second light curve. A
first version tinted noon by the sky's colour and turned Base-Shader's gold
block brown on lavapipe, which is the kind of mistake only a frame shows.
The limit is multiple render targets¶
/* DRAWBUFFERS:0125 */ is a gbuffers program declaring it writes four
targets at once. Godot spatial shaders write a fixed set of built ins,
ALBEDO, NORMAL, ROUGHNESS, METALLIC, EMISSION, into Godot's own
pipeline. There is no MRT, so a program writing custom data to colortex5
cannot be expressed and that write is simply lost.
This is the real ceiling on the translation half, and it is worth being precise about rather than discovering late:
- The common conventions map:
colortex0toALBEDO, normals toNORMAL, a specular or LabPBR target toROUGHNESSandMETALLIC. A pack following the usual layout is mostly expressible. - A pack storing bespoke data in a spare target loses it, and whichever composite pass reads that target gets nothing.
Material::set_next_passcan emulate MRT by drawing the geometry again per target. It costs a full geometry pass each and is worth it for two, not for eight. It also draws into the same colour buffer, so on its own it does not reach acolortex; each extra pass would need a target of its own.
So: most packs mostly work, a pack with an unusual G buffer layout does not, and the failure is legible rather than mysterious once the DRAWBUFFERS declaration is parsed and compared against what can be expressed.
What was built (2026-10-12) is the extra geometry pass per target, drawn
by a camera of its own into a target of its own rather than by
set_next_pass, and with every output kept rather than mapped onto
Godot's built ins; see "Second outputs" below.
The LOD terrain has to be the same terrain¶
docs/systems/far-rendering.md draws distant terrain as coarse tiers. If those
tiers run the same node array shader against the same vertex layout as the
near mesh, then translating gbuffers_terrain into that shader covers the
vista for free. If they do not, the pack shades the near bubble and the
horizon stays as Godot drew it, which is the familiar broken look of an LOD
mod under a pack with no programs for it; Iris had to grow separate Distant
Horizons programs and a second depth texture to fix exactly that. Goanna
avoids it by having the LOD vertex layout carry lmcoord, the occlusion
term and the block ID from the start, and by drawing far terrain in the same
scene against the same depth buffer. That is why far rendering rungs 2 and
3 precede rung 5 here in docs/design/roadmap.md, and it is a real dependency
rather than a preference.
The order of work¶
- Parse a pack and prove the pipeline. Done, 2026-08-21, see the status
section.
shaders.properties, the program list, thecolortexformat directives. Allocate the buffers, compile one trivialfinal.fshthroughshader_compile_spirv_from_source, run it as aPOST_TRANSPARENTeffect. This proved the approach in a day, which is why it sat first on this page and early indocs/design/roadmap.md's order: its answer decides how much tonemap, bloom and reflection work is worth doing in.gdshaderunderdocs/design/pbr-plan.md, "Lighting and exposure". The answer is: little, packs bring it. - The uniform set. Iris supplies a large, well documented set:
gbufferModelView,shadowLightPosition,frameTimeCounter,sunAngle,cameraPositionand the rest. Goanna has all of these or can compute them. This is bookkeeping, not difficulty. - The preprocessor. Discussed below; the real work.
- The screen space chain in order, with the ping pong buffer rules and
/* DRAWBUFFERS: */honoured. The machinery is in and proven on a two pass pack; what remains here is the option and custom uniform parsing, and real packs. - Translate
gbuffers_terrain, unshaded, into the node array shader path. One program, the most visible one, and the first real test of the translator. Its DRAWBUFFERS declaration says immediately whether the pack is expressible. The target is Base-Shader; the section below says why and lists what it asks for. Built and observed on the GPU, near and far, 2026-10-11; see "The gbuffers translator, as built". gbuffers_water, then the rest. Water is the one whose absence is most obvious, and it exercises vertex displacement. Base has no water program, so this rung is measured against Eclipse. The translation runs, displacement included. Eclipse draws its water incompositefrom targets only a second output can reach, so until 2026-10-12 its water surface was transparent; the second outputs now reach them, and on the GPU its water is drawn, tinted and reflecting the stage. See "Second outputs" below.- Shadow. This item first assumed Godot's own shadow pass would do,
given vertex displacement that agrees with the terrain program. It does
not: a pack looks its shadows up in a map drawn by its own
shadowprogram, with its own matrices and distortion, and Godot's shadow atlas is neither. Built 2026-10-12 as a pass of Goanna's own that draws the near terrain with the translated shadow program into the pack's map, read by the gbuffers programs and the chain; observed on the GPU with Eclipse, on the stage and onr1tod1. See "The shadow map, as built".
Rungs 1 to 4 are the screen space chain and need no translator at all. Rungs 5 to 7 are the translator, and each is separately useful: a pack with only its composite chain running already looks like a shader pack.
The gbuffers translator, as built¶
Built 2026-10-11 by rungs 5 to 7 above. The parts:
src/iris/iris_preproc.cpp, the preprocessor the whole loader shares (see "The preprocessor, which is the hard part"). The translator runs it in full on each stage the loader hands over, because a declaration inside an#ifdefcannot be parsed before the#ifdefis decided and Godot's shading language has no preprocessor of the right kind.src/iris/iris_glsl_ast.cpp, a parser into a syntax tree.src/iris/iris_gbuffers.cpp, the translator. It types every expression as it emits it in Godot's shading language, and that turned out to be the heart of the job rather than "expression level work inside a fixed harness": Godot converts nothing implicitly (vec2(int, int)is an error), has no global variables, no function prototypes, no comma operator, no non-square matrices and no shadow samplers, will not let a local share a global's name, builds a matrix only from whole columns, and lets no function butvertex()andfragment()write a varying or read a stage built in such asFRAGCOORD. Each has a rewrite: the conversions are written out; a global initialised from a uniform becomes a function returning its initialiser, a constant one aconst, and any other a local of the main body; varyings, outputs and such globals a helper writes are passed to it asinoutparameters, and the built in inputs it reads asinones, which costs no interpolator; functions are emitted callees first; and a function both stages include is emitted once.- Varyings are packed (since 2026-10-13). On the GPU Godot counts each
varying declaration as one against a limit of 32, with its own spatial
varyings included: MakeUp's terrain declared 21 and Godot counted 33. The
front end, which does not count, accepted it. Every pack varying is a
local of
vertex(), copied at its end intogoanna_pkslots of four components, one set per base type and interpolation (smooth float, flat float, flat int, flat uint), filled largest first and split over the room left when that saves a slot;fragment()starts by unpacking a local of each, which helpers get asinoutparameters like any other local of main. A varying the fragment stage never names gets no slot,gl_FogFragCoordis carried the same way, abooltravels as a flatint, a matrix by its columns and a struct field by field. MakeUp's terrain went from 21 declarations to 10, its water from 24 to 15; no translated program of the seven packs checked declares more than 15. - Fragment outputs keep the type the pack declared (Photon's shadow
program writes a
vec3) and are widened to avec4where they reachALBEDO, as GL fills missing components. project/shaders/iris_gbuffers_shell.gdshaderinc, the hand written shell. It answers the pack's inputs from the node mesh contract (docs/systems/mesh-attributes.md):gl_Vertexthe world position,gl_Colorthe tile tint times Goanna's traced occlusion and, witholdLighting, Minecraft's face shading (top 1.0, bottom 0.5, sides 0.8 and 0.6),gl_MultiTexCoord1the block and sky light,mc_Entity.xthe pack'sblock.propertiesnumber for the node's block,at_tangent,mc_midTexCoordandat_midBlock, andgl_FragCoordin OpenGL's convention with a depth the pack's owngbufferProjectionagrees with. The atlas is the material's texture array sampled at the face's layer, withoutsource_color, so the pack reads gamma values as Minecraft gives them, and its colour output is converted to linear on the way intoALBEDO.src/iris/goanna_iris_gbuffers.cpp,GoannaIrisGbuffers: loads the programs throughiris::Pack, with the same options, overrides (GOANNA_SHADERPACK_OPTIONS),program.*.enabledand macros as the chain, and with Iris's fallbacks (gbuffers_terrain_cutouttogbuffers_terraintogbuffers_textured_littogbuffers_texturedtogbuffers_basic; water to terrain first). It registers the Godot global parameters the translated code reads and sets them each frame from the player's camera: the Iris matrices (gbufferModelViewis the camera's rotation alone, so player space is world lesscameraPosition, as in Minecraft), the fixed function stand ins, the sun, time, fog, rain, the lightmap, and the pack's custom uniforms, evaluated from those values by the same evaluator the chain uses.GoannaClientputs the shaders on four surfaces: opaque array tiles (gbuffers_terrain), cut-out array tiles (gbuffers_terrain_cutout, alpha tested atalphaTestRef), the single texture leaves and plants (the cut-out program again), and water (gbuffers_water, blended). The far tiers share the array materials, so they take the same programs with nothing added for them.
GOANNA_SHADERPACK loads it beside the screen space chain, and
GOANNA_SHADERPACK_GBUFFERS=0 leaves it out for comparison. Without a pack
none of it is created and every material is as it was.
A displaced vertex. A pack writes gl_Position in clip space with the
player camera's matrices. The shell takes it back to a world position and
writes VERTEX, so Godot transforms it for whatever pass is running, the
shadow pass included. It rebuilds the view position from x, y and w,
which a perspective projection keeps exactly; rebuilding from z, where
depth precision went, would move a vertex at a thousand nodes by about half
a node (worked out, not measured). Plain ftransform() leaves VERTEX
alone.
Second outputs¶
Built 2026-10-12 (commit e6c842a1). A program writes more than its
colour: Eclipse's water puts a water mask in colortex4, its normal in
colortex1 and its sky light in colortex3, and its terrain its normal
in colortex1, and the chain reads all of them. Godot's spatial shader
has one colour output, so each target is drawn by a pass of its own.
- The translator keeps every output (
GbuffersResult::second): eachcolortexfrom 1 to 15 a program writes, and the colour's own target when that is notcolortex0, so a pack keeping its colour incolortex1finds it there.fragment()ends in a branch onCAMERA_VISIBLE_LAYERS: the five cull mask bits from bit 15 (iris::kGbuffersPassShift) hold the colortex plus one under a second output camera, and there the program writes that output raw toALBEDO(andALPHAwhere blended). A target the program does not write gets 0, or alpha 0 where blended, so an opaque surface still hides what is behind it. Every ordinary camera, and the shadow pass, has all five bits or none, and gets the colour as before. game/effects/iris_targets.gdmakes oneSubViewportper target some program writes and the chain samples (GoannaIrisEffect::reads_colortex), up to eight. Each shares the world, renders HDR with a transparent background, an environment with no sky, fog, glow or exposure and a linear tonemap, and an emptyCompositorso the chain does not run in it too. Its camera follows the player's every frame, at the main view's 3D resolution, and sees only layer 15 (NODE_SURFACE_LAYER, which the node meshes near and far carry beside layer 1) plus its marker bits.GoannaIrisEffect::set_gbuffers_targettakes each viewport's texture every frame, and the chain copies it into the colortex at its start in place of the clear.-
Translated water writes depth when the pack has a screen space chain, as Minecraft's does, so
depthtex0holds the water surface anddepthtex1does not, which is how Eclipse measures the water it looks through. Not without a chain, which is the only reader: written depth also hides any translucent surface Godot sorts after the water. Base-Shader's frames onr1tod1were the same with and without it (0.8 per cent of pixels differing by more than 15 levels, looking down on a lake). -
A target only the water program writes (Photon's translucent layer in
colortex13, Eclipse'scolortex3) cannot be left clear by opaque terrain in its pass: the terrain writes zero there and Godot gives an opaque surface alpha 1, so Photon's chain found an opaque black layer over all its land.GoannaIrisGbuffers::translucent_targets()names such targets, and the chain copies them only wheredepthtex0(with translucent geometry) is nearer thandepthtex1(without), leaving the clear elsewhere, as Minecraft's masked write does (since 2026-10-13).
GOANNA_SHADERPACK_SECOND=0 leaves the targets cleared, for comparison.
What it costs and where it stops:
- A whole geometry pass per target: four for Eclipse (
colortex1, 3, 4 and 6). Not measured. - Values pass through a half float target, so a pack packing bits into a
16 bit target (Photon keeps its material data in
colortex1and 2 asRGBA16) loses them. - Only node surfaces are in these views. What else is in a node mesh (Goanna's own grass) draws there with its own shader, which no light reaches, so near black; entities, the sky and particles are absent and hide nothing.
- Whether Godot renders the subviewports before the main view in the same frame, or a frame late, was not checked; a still camera cannot show it.
The shadow map, as built¶
Built 2026-10-12 for rung 7. A pack's shadows come from its shadow
program drawn into a map its own lookups agree with, distortion and all;
Godot's shadow atlas is not that. The first slice draws the near terrain
only. The parts, and the choices behind them:
- What is drawn. Copies of the near terrain meshes, nothing else: the
regional batches and the per block special meshes, which are what
GoannaClientgives terrain materials to. Each copy is a childMeshInstance3Dnamediris_shadow(GoannaClient::irisShadowTwin), sharing the mesh, on render layer 13 alone, casting nothing into Godot's shadow maps and out of GI. Each surface takes the shadow program translated for its shell (opaque array tiles, cut-out array tiles, foliage), with the base material's uniforms copied; a surface the pack draws no shadow for (water, glass, lava, glow, anything not on a pack program) takes a material that puts every vertex on one point outside the clip volume. The far tiers are not drawn: they share the array materials, but have no copies, and most of them lie beyond a pack'sshadowDistance(160 by default) anyway. - How the meshes are reached. Through Godot's renderer, not a
RenderingDevicedraw of Goanna's own. Godot 4.5 exposes no mesh's vertex buffer as an RD resource, so a pass of our own would need a second copy of every near mesh uploaded by hand and kept in step with every remesh. ASubViewportsharing the world, with an orthographicCamera3Dwhose cull mask is layer 13 alone, draws exactly the copies and nothing else: entities, sky, particles and lights are all on other layers, so Godot does no lighting, no shadow maps and no sky for it, and its ownEnvironmentclears to white with fog, glow and GI off. The camera is the pack's shadow box (shadowDistanceeither side, 0.05 to 256 deep, 100 nodes out along the light), so Godot's culling is the pack's. - Where the pack's maths runs. In the translated shadow program
(
GbuffersOptions::shadow_pass). Its fixed function matrices are the light's (iris_shadow_gl_ModelViewMatrixisshadowModelViewafter the camera's translation,gl_ProjectionMatrixisshadowProjection), softransform()and the pack's ownshadowProjection * shadowModelViewagree. Itsgl_Position, displacement and distortion included, goes straight toPOSITION: x, y and w as written, so the map's texels sit where the pack's lookups put them, and z moved from OpenGL's-w..wto the reversed0..wGodot's depth test wants (goanna_shadow_positionin the shell). Its colour reachesALBEDOunconverted, forshadowcolor0. - Keeping the program out of other cameras. The shadow program writes
its position only when
CAMERA_VISIBLE_LAYERSis exactly layer 13, the shadow camera's mask; in any other camera every vertex lands on one point outside the clip volume. The player's camera also drops layer 13 from its mask, so it does not even cull the copies. Layer 13 (bit 12) is clear ofNODE_SURFACE_LAYER(bit 14) and of the second output pass bits (15 to 19), which the shadow camera leaves all clear, so the programs draw their colour there. - The matrices (
src/iris/iris_shadow.cpp, pure arithmetic with a native test).shadowModelViewlooks from the light at the player: view +z is the direction to the sun by day and the moon by night (the same choice asshadowLightPosition), x stays across the sun's path so the map does not spin, the player is 100 nodes out, and the frame snaps toshadowIntervalSize(2 by default) so texels do not crawl as the player walks.shadowProjectionis the orthographic box above. Both are built from Goanna's own sun, not fromsunAngleand the pack'ssunPathRotation: the shadows have to fall from the sun the server shows, sosunPathRotationis read, reported and not applied. The settings come from the pack's const directives (shadowMapResolution,shadowDistance,shadowIntervalSize), an option's name resolved to its value; a perspective map (shadowMapFov) is reported and drawn orthographic.GoannaIrisGbuffers::updatesets the matrices for the shadow program and every gbuffers program,main.gdmoves the culling camera and hands them to the screen space chain, all from the same numbers in the same frame. - Keeping the result.
GoannaIrisShadow, aCompositorEffectin the shadow camera'sCompositor, copies the viewport's depth and colour atPOST_TRANSPARENTinto textures of its own, made once at the first frame's size: shadowtex0 asD32(a comparison sampler in the chain needs a depth format) and asR32F(for Godot shaders, through aTexture2DRD), both in OpenGL's convention (0 at the light's near plane, 1 where nothing was drawn), andshadowcolor0asRGBA16F.GoannaIrisEffectbinds the depth copy forshadowtex0andshadowtex1and the colour forshadowcolor0; the gbuffers programs read theTexture2DRDs as the globalsiris_shadowtex0,iris_shadowtex1andiris_shadowcolor0, sampled nearest. Asampler2DShadowlookup in a gbuffers program, which Godot's shading language cannot express, is written out as astepagainst the stored depth. Until the first map is drawn every one of them reads white, so nothing is in shadow, as before the pass existed. Frees go through the render thread in a callable that owns only the RIDs, after theTexture2DRDs have let go of them. - Synchronisation. Godot draws a
SubViewportbefore the viewport that contains it, so in a frame the map is drawn and copied before the player's view samples it, all on the render thread, ordered by the render graph's barriers. The matrices are set on the main thread before the frame is drawn, so the map and its lookups use the same ones. Were the order ever the other way, the lookups would be one frame behind the map.
What the first slice leaves out: shadowtex1 is the same map as
shadowtex0, because no translucent geometry is drawn into it, so a pack's
coloured shadows through glass and water (the shadowtex0 against
shadowtex1 test) never trigger; entities, the player, the far tiers,
glowing surfaces and the procedural grass cast nothing; shadowcolor1,
shadowcomp and per program shadow variants (shadow_solid,
shadow_cutout) are not read; shadowDistanceRenderMul and the
shadow.culling directives are ignored; and a pack that sets
sunPathRotation sees its shadows fall from Goanna's sun. With
GOANNA_SHADERPACK_SHADOW=0 the pass is left out and the map reads as all
lit, for comparison.
What was observed¶
Offline, no GPU, Godot 4.5.1, 2026-10-11: goanna_iris_gbuffers_test (in
cmake --build build --target check) and tests/iris_gbuffers.gd pass,
the second against Base-Shader 3.0, which is CC0-1.0 and is in
project/tests/shaderpacks/base-shader/ as test data. With
GOANNA_IRIS_PACK pointing at them, the terrain, cut-out, water and
foliage programs of Eclipse 1.0, Mellow 3.4.1a, BSL 10.1.8, Complementary
Reimagined r5.9.3 and MakeUp Ultra Fast 9.5g also pass Godot's shader
front end (and, before the reboot that day, those of Miniature, Sildur's
Enhanced Default and Photon's terrain; Photon's water puts a struct in a
varying, which Godot refuses). The front end is not the GPU backend: it
does not count varyings, and Mellow passed it and then failed on lavapipe
with 33 against Godot's limit of 32, which is what moved the built in
inputs from varyings to parameters. Complementary's translation crashed
Godot's compiler outright, because constant folding mat4(1.0) * vec4(...)
indexes past an array in Godot 4.5.1; the translator drops an identity
texture matrix rather than write that expression, and the client would
otherwise crash on load.
On lavapipe, before the reboot, through tools/goanna-render --software
(640 by 360, the fixture world): the first Base-Shader frame turned the
gold block brown, which led to the white noon lightmap; Mellow failed with
the varying limit above; and Mellow's terrain, once compiling, was black,
because its lighting reads custom uniforms that then read zero.
On the GPU, the same evening, through tools/goanna-render on an RTX
3090, 1280 by 720, Luanti 5.17.0 server, Mineclonia (release 38561),
Godot 4.5.1, the branch clean at the commit named:
- Base-Shader, near and far (
r1tod1, a v7 Mineclonia world, at36fe743e): the near bubble, the far tiers out to the horizon and the water all drawn by the pack, in its flat face shaded look, with its own fog closing on the far tiers in the fog colour; no shader errors in the client log. The far tiers needed nothing of their own, as "The LOD terrain has to be the same terrain" predicted. - Base-Shader on the fixture's stage (at
f745b425): grass, stone bricks, gold, oak leaves (now through the foliage surface) and water, beside the same stage with no pack. - Eclipse on the stage: terrain drawn through
gbuffers_textured_lit(it has no terrain program) and nearly black, with the whole frame dark under its chain; the water surface transparent, as its water program writes colour 0 with alpha 0 for water and puts the water incolortex4,colortex1andcolortex3, which are dropped and listed inreport(). Not traced further. - Mellow on the stage, with custom uniforms: no longer black, but the whole frame is a pale haze over faint shapes. Its chain runs over the result, and which half makes the haze was not traced.
On 2026-10-12 and 13, the same card, server, game and Godot, through
tools/goanna-render on the fixture's stage (grass, stone bricks, gold,
oak leaves and a water source spreading over the stone floor, noon,
clear), the branch clean at the commit named; frames compared by eye and
by averaging regions of the PNGs:
- At
19f48140, before this work: Base-Shader as on 2026-10-11; Eclipse dark with its water transparent, as above. The client with Eclipse loaded was stopped through the launcher with no signal 11 and no core dump, where on 2026-10-11 both pack clients crashed; that is a stop, not a quit from inside. - A debug copy of Eclipse whose
finalshows the targets:colortex1holds the normals (the floor (0.5, 1, 0.5), the block faces towards the camera blue, the water's wave normals rippling), and where the water is,colortex4andcolortex3are 1 and the rest 0, edges where they should be; the sky and Goanna's grass are 0. So the second outputs reach the chain, raw and aligned. - Eclipse with second outputs, at
e6c842a1: the water drawn, pale blue-grey with the darker shapes its screen space reflections find, where without them (GOANNA_SHADERPACK_SECOND=0) the floor shows through; the rest still dark. - Mellow split into its halves: the geometry programs alone (the chain's
files removed from a copy) give a lit, recognisable stage; the chain
alone (
GOANNA_SHADERPACK_GBUFFERS=0) gives the haze; withBORDER_FOG=falsethe haze is gone. A probe pack then showednearandfarreading -0.05 in the chain, the projection fault described in the status section, fixed in0fce1c1b. - At
0fce1c1b(this run's service had another session's copy ofr1tod1for its world, so real terrain stands behind the stage): Mellow draws the stage by day under its own sky and fog, the terrain beyond the stage clear. Eclipse is in daylight: a blue sky with clouds, the water blue and reflecting the grass and the blocks in it, the floor a dark blue-grey; its block faces stay dark, which is its own lighting for faces the noon sun does not face (shadowColorfor a surface edge on to the light), not compared with Iris. Without second outputs the water is a flat dark plane with no reflection. Base-Shader is as before, region by region within a level or two, except Goanna's own grass, which the pack does not draw and whose colour differed in every run, the baseline included. Photon, now translating, draws its terrain magenta under the 15 of its 20 chain passes that compile. - At
0fce1c1bonr1tod1, near (110 nodes up, looking down on the lake and along it) and far (240 up, out to the horizon): Eclipse's lake is blue water with fine ripples and the islands reflected dark under them, out to where the far tiers take over; the land and trees are washed white by its fog, which is thick beyond a few tens of nodes; from 240 up only the water and the sky keep their colour. Without second outputs the lake is a flat dark grey under the same fog. Mellow is clear and green near and far, its border fog closing the far tiers at the camera's far plane; its lake reads as the lake bed in pale squares, the water itself nearly clear. Base-Shader is as on 2026-10-11.
The shadow map, 2026-10-12, through tools/goanna-render on the RTX 3090
(NVIDIA 615.71.09), 1280 by 720, Luanti 5.17.0 server, Mineclonia
(release 38561), Godot 4.5.1, Eclipse 1.0 with SSAO_ENABLED=false (its
SSAO reads normals and turns the stage nearly black), each frame against
the same with GOANNA_SHADERPACK_SHADOW=0:
- On the stage, at
24530daa(the shadow commit before the rebase onto rung 6, with the debug overlay uncommitted): the shadow viewport, shown in a corner withGOANNA_SHADERPACK_SHADOW_SHOW=1, holds the stage seen from the sun, barrelled by Eclipse's distortion, the trunk, leaves, pillars and gold slab where they stand, and the keptshadowtex0is the same shape in depth, 1.0 round it. WithSOFT_SHADOWS=falsethe floor takes Eclipse's teal shadows of the leaf canopy, the pillar, the wall and the slab on its posts, at 8:24 in the morning falling west, as Godot's own shadows fall in the frame without a pack, and at 15:36 falling east. With Eclipse's default soft shadows the frames with and without the map differ in fewer than 400 pixels: its filter scores a point fully in shadow 0.15 (docs/develop/shaderpack-status.md, "The shadow map"). A probe pack of ours (a plainshadowprogram and a terrain program that halves the light where its lookup is in shadow) shows the same shadows, hard, with the jagged edges a 1024 map over 320 nodes gives without distortion. - On
r1tod1, with the code as committed, on rung 6 (b41c0f40): from 14 nodes over a frozen lake looking at a snowy island with spruces, the snow tops west of the spruces are in shadow in the morning and lit in the afternoon, and from 22 up looking north the steps of the slope to the east are shadowed in bands on the side away from the morning sun. The ice, which Goanna draws with its own shader, takes no shadow. The far tiers are as without the map. The dark blue grid on the lake behind the island is in the frames without the map too.
MakeUp and Photon, 2026-10-13, through tools/goanna-render on the RTX
3090, 1280 by 720, Luanti 5.17.0, Mineclonia, Godot 4.5.1, a copy of
r1tod1, noon, clear, three poses (110 nodes up looking down on the lake
and along it, and 14 up facing a snowy island); Base-Shader, Eclipse and
Mellow beside them for regressions:
- Before (main at
5575c88a), from the client logs of the run that queued this work: MakeUp's frame black, with "Too many varyings used in shader (33 used, maximum supported is 32)" from its geometry programs; Photon magenta, with "Invalid arguments to operator '=': 'vec4, vec3'" (its shadow program'svec3output) and 5 of 20 chain passes failing. - At
e3848de3(varyings packed, Photon's chain compiling): no shader errors in either client log, the render service's problems list empty for every job, and the effect reporting MakeUp 5 of 5 and Photon 20 of 20 passes compiled. MakeUp draws: its clouds, its terrain dark and fading to black with distance, its water black. Both are its geometry programs reading targets Goanna cannot give them: its terrain fogs towardsgaux4, the sky colour itsgbuffers_skybasicwould write, and its water refractsgaux1, the opaque scene itsdeferredpass copies before Minecraft draws translucent geometry; both read black. Photon drew a quarter size copy of its frame in the top left corner over black, which wassize.buffer(its debug view ofcolortex5showed the copy already in the history buffer, and turning bloom and TAA off changed nothing). - At
b9bffecc(size.buffer, raw textures): Photon fills the frame with its sky, horizon haze and fog over dark land silhouettes. A debug copy of Photon writing its decoded albedo fromdeferred4still showed the land black, which led to the water only second output above. - At
c0ecc01d: Photon draws the land lit in its own palette, warm sandstone and dirt, green and dark spruce canopies, snow on the island and its spruces with soft contact shading, a deep navy sky going hazy at the horizon, and red and white specks where its lake surface meets the lake bed. Its water is near black, and from 14 nodes up the frozen lake reads as its terraced bed, the ice not shown. Eclipse's frames differ from the build before in under 3 per cent of pixels; Base-Shader, Eclipse, Mellow and MakeUp atb9bffeccdiffer frome3848de3in under 4 per cent, and each looks as in the entries above.
Not observed: any displaced vertex (Eclipse's waves, Mellow's waving leaves) on the GPU; any pack at night, in rain or under water; the shadow map at night; performance, the shadow pass's included.
Additional camera and world inputs¶
Both the screen space chain and geometry programs receive the integer and
fractional camera position, their previous frame values, camera movement
per frame (velocity) and the server's cloud height. Geometry custom
uniforms can override a standard uniform, as they can in the chain.
The added player flags and end-flash uniforms currently have false or zero
fallbacks; firstPersonCamera is true. These declarations do not establish
support for every Minecraft player state or dimension effect.
Targets a geometry program reads¶
Geometry programs can sample colortex0 to colortex15, including the
legacy gaux names. The translator records the targets it reads and
registers texture globals. Translucent programs also read the opaque scene
through Godot's depth texture, with reversed depth converted to OpenGL
window depth. Screen coordinates and gradients are flipped; targets with
absolute size.buffer dimensions retain their data map coordinates.
This is an approximation of Iris's ordering, not full deferred rendering:
- Geometry reads receive the actual target after the prepare and deferred
passes, available on the next frame. Targets written beside colour can
store depth or masks in alpha, so they are not replaced with native
scene colour.
texture.gbufferscustom images are not loaded here. - Fragment
prepareprograms and their numbered variants run before deferred in the screen chain. Where they write a background into a geometry target, existing geometry pixels are restored over it using depth, leaving the prepared sky intact. This does not move execution before Godot's geometry; geometry still sees the previous snapshot. - A sampler passed through a helper function does not receive the direct call's coordinate conversion; the translator reports this limitation. Offset and gather operations still have the existing approximations.
report().gbuffers_reads names the source used for each target. Copies use
RGBA32F, which Godot can bind through Texture2DRD. This avoids losing
further bits in the copy. UNORM16 secondary targets now bypass half-float
precision loss through the bit transport described below. Other formats
still retain the precision limits of their source viewports.
On resize the render thread creates replacement textures; the main thread
queues their binding before old textures are released on the render
thread. iris_teardown.tscn exercises resizing and disposal.
The chain honours legacy format and clear declarations as well as modern
names. New targets are initialised to zero even when Clear=false, and
the last written ping pong side is retained across frames. The small
tests/shaderpacks/history pack checks accumulation independently of a
real pack's exposure or temporal filter.
Colour and packed material transport¶
When geometry has a screen space chain, its primary colour reaches that chain unchanged. It is not clamped to 0..1 or decoded as display colour: Eclipse can write gamma-encoded colour while Complementary can write linear HDR into its declared floating-point target. The chain's final display output is decoded with the sRGB transfer function before Godot's output encoding. Standalone geometry retains display-colour conversion.
Declared UNORM16 secondary targets use two RGB viewports. Each carries three groups of at most eleven bits as exact multiples of 1/2048. The copy reassembles four 16-bit channels before storing the requested target. This also preserves the fourth material channel, which ordinary opaque viewport alpha cannot carry. The extra pass counts towards the eight-pass limit. Blended geometry cannot preserve this bit encoding through colour blending; its translation reports that limitation. Other secondary formats still pass through half floats, and opaque alpha in those formats is not preserved.
The tests/shaderpacks/hdr-handoff diagnostic pack writes HDR colour and
four distinct 16-bit values, then checks them in final. On 14 October 2026
it produced the expected terrain pixel (186, 136, 186) against the
Minecraft Java 26.2 comparison server through BridgeTest, Godot 4.5.1 and
the RTX 3090 in headless gamescope. Sky and blended water are negative
controls in that fixture; this verifies opaque transport, not all blending.
Pack sky geometry¶
The loader translates gbuffers_skybasic and gbuffers_skytextured, with
Iris's basic and textured fallbacks. A dome centred on the camera receives
the server's sky colour. Stars and textured sun/moon geometry use the same
programs with their own renderStage values. Sky vertices keep the pack's
projected direction at the far depth, without writing depth. The secondary
output cameras include this geometry, so sky masks reach the chain too.
When this sky program is present, the native sky background steps aside. The pack may draw procedural stars, sun and moon in its dome shader and suppress the supplied geometry itself. These meshes approximate Minecraft's sky geometry; the server still supplies celestial textures, time and star settings. Custom sky textures and the separate sunset gradient are not implemented. Missing custom samplers read transparent black and are reported.
What is left¶
- The second outputs' cost and precision. Built (see "Second
outputs"), but each target is a whole geometry pass, not yet measured,
and ordinary values pass through half floats. UNORM16
material targets now use the two-pass bit transport described above.
Multiple render targets in one pass would need the geometry drawn
through
RenderingDevice, which neither the secondary outputs nor the shadow map do. Of the packs whose terrain programs were checked, BSL and MakeUp writecolortex1and Complementarycolortex3, 4 and 6 besides the colour. All six checkpoint packs have now been captured with them. - Photon's water and its approximations. It runs whole since
2026-10-13 (above), but its water is near black and its ice is not
shown, not traced. Its sky light comes from a compute pass
(
deferred4_a,SH_SKYLIGHT) Goanna does not run, so that ambient term reads zero; its UNORM16 material data now uses exact opaque transport, but blended material data remains approximate. History now retains the last written ping pong side, but explicitflipdirectives remain unsupported. Not compared with Iris. - Geometry inputs and pass ordering. Geometry can now read copied colortex targets and opaque depth, as described above. Full fidelity needs the deferred chain before translucent geometry and geometry custom textures. Target copies arrive one frame late.
- The rest of the shadow map. The first slice draws opaque, cut-out
and foliage near terrain only; see the end of "The shadow map, as
built" for what it leaves out: translucent casters and so
shadowtex1and coloured shadows, entities, the far tiers, glow and Goanna's own grass,shadowcomp, the per program shadow variants,sunPathRotation. Its cost is not measured: a second scene render of the near terrain, with Godot's depth prepass, each frame. - Other scene geometry: entities, clouds, the hand, particles
and the procedural grass keep Godot's shaders; their programs
(
gbuffers_entities,gbuffers_cloudsand the rest) are not loaded. atlasSizeis a single tile's 16 by 16 because there is no atlas, so a pack that does arithmetic on atlas coordinates (parallax, neighbouring tiles) is not answered;moonPhaseandworldDayremain constants. Eye brightness now samples the camera node's raw light banks, with the pack's smoothing half-life. Sky light is conservative where lamp light dominates the daytime bank; Luanti does not send separate sky values.- A pack whose translation Godot's compiler crashes on takes the client with it. One such construct is avoided; others may exist.
- Local multiplayer: the globals are not scoped per view as
RenderScopescopes Goanna's own, so two players with a pack would share one camera.
The first target, and why it is Base-Shader¶
Read on 2026-08-22, from the pack sources rather than from their pages.
Base-Shader is CC0-1.0, source at github.com/Bestsoft101/Base-Shader. Eclipse
is CC-BY-4.0, distributed as a zip through Modrinth with no public source
repository. Both are licensed for Goanna to ship, Eclipse with attribution. An
earlier draft of docs/develop/launch-target.md said no third party pack was
licensed for us to ship. That was wrong, and it is the reason this plan changed.
Base-Shader has no screen space chain at all. Fourteen gbuffers_*
programs, block.properties, entity.properties, fog.glsl, and no
deferred, composite or final. So it is not a pack the machinery from
rungs 1 to 4 can run: Pack::load succeeds because it finds .fsh files,
compositeChain() comes back empty, no passes are built and the frame is
untouched. A pack that loads and changes nothing is the worst failure
available here, because nothing reports it, and GoannaIrisEffect::report()
should say so rather than leave it to be noticed.
That is also what makes it the right first target for rung 5. Every one of
its programs is the same handful of lines. gbuffers_terrain entire:
// vsh
color = gl_Color * texture2D(lightmap, clamp(gl_MultiTexCoord1.xy / 255.0f,
0.5f / 16.0f, 15.5f / 16.0f));
texcoord = gl_MultiTexCoord0.xy;
gl_Position = ftransform();
// fsh
vec4 albedo = texture2D(texture, texcoord) * color;
albedo.rgb = mix(albedo.rgb, gl_Fog.color.rgb,
getFogStrength(fogShape, gl_Fog.start, gl_Fog.end));
gl_FragData[0] = albedo;
There are no normals, no second render target, no vertex displacement, no parallax and no shadow sampling anywhere in the pack. So the MRT ceiling described above is never reached, and rung 5 against Base is a hand written shell plus a substitution table, which is the shape this page predicted the work would take.
| What the pack asks for | What Goanna answers with |
|---|---|
gl_Vertex, gl_Color, gl_MultiTexCoord0 |
VERTEX, COLOR, UV |
gl_MultiTexCoord1 (lmcoord) |
CUSTOM0.rg * 240.0 |
ftransform() |
Godot's own transform: leave VERTEX alone |
texture2D(texture, uv) |
texture(albedo_array, vec3(uv, UV2.x)) |
lightmap |
generated per frame from the sky and torch colour |
gl_Fog.color, .start, .end, fogShape |
uniforms from the drawn distance |
mc_Entity.x |
UV2.y, the block semantic ID |
gl_FragData[0] |
ALBEDO and ALPHA, under unshaded |
MC_RENDER_STAGE_* |
preprocessor constants |
The one genuine mismatch in that table is the atlas. Minecraft hands a pack
one 2D atlas and the pack's texcoord indexes it directly; Goanna's node
material is a sampler2DArray with the layer in UV2.x. Base never does
arithmetic on texcoord, so the substitution is exact for it. A pack that
offsets within the atlas, for parallax or for a neighbouring tile, cannot be
answered this way and will have to be met with an atlas built for the
purpose. Base not needing that is precisely why it goes first.
Eclipse is the second target, and it is a different shape. It has
composite, composite1 and final, plus gbuffers_water and shadow,
so it exercises rungs 6 and 7 where Base does not. Four things about it are
worth knowing before starting:
- Its
final.fshis an identity pass. The ACES fit is written out in full and then commented out of the call. So "the pack's final pass is visible" cannot be a test against Eclipse. composite.fshwraps its whole body inif (water >= 0.1), wherewateriscolortex4.rwritten bygbuffers_water, andcomposite1.fshreads its normals fromcolortex1. Without the geometry programs those targets are never written, so the chain is inert. Eclipse is not a rung 4 pack that happens to have geometry; it is a whole pack or nothing.- It is
#version 330 compatibility, not 120:in,out,layout(location = 0) out vec4,texture(), andRENDERTARGETSrather thanDRAWBUFFERS. The translator already handles both comment forms and both declaration keywords, but it blanks only unlocatedinandoutdeclarations, so an already located output will collide with the output emission and needs handling. composite1.fshdeclaresvec3 sample.sampleis reserved from GLSL 4.20, so it is rejected at the#version 450the translator emits. A pack is entitled to that name in its own dialect, so renaming reserved words is the translator's job, not the pack's problem.
Fog ownership, settled¶
A pack that supplies gbuffers_terrain does its own fog in that program,
which is what Base's fog.glsl is for. Godot's environment fog must then be
switched off, or terrain is fogged twice, once by main.gd and once by the
pack. Unlike the tonemap rule this needs no declaration from the pack and no
Goanna specific property: supplying a terrain program is the signal, because
a pack that has one has taken over the surface the fog is applied to.
The rule, then, and it applies to every pack rather than to ours alone:
- The pack has
final: Godot's tonemap goes linear. Already implemented inproject/game/main.gd. - The pack has a translated terrain program: the drawn distance
main.gdalready computes (the environment's depth fog begin and end) is handed to the pack asgl_Fog.startandgl_Fog.end.faron its own is the camera's far plane, not what Goanna draws, so a pack scaling fog byfarwould fog the far tiers wrongly. As built on 2026-10-11 the environment's fog is not switched off: every translated shader takesrender_mode fog_disabledinstead, which keeps Godot's fog off exactly the surfaces the pack fogs and on everything else (the sky, entities, the procedural grass), with no change tomain.gd's fog writes. The same holds for the volumetric cloud layer, so a camera inside it sees pack terrain through it while Godot's own surfaces stay clouded. The tonemap goes linear, as for a pack withfinal, because the terrain program writes display colour. - Neither: Godot keeps both, as today.
What unshaded costs, stated plainly¶
render_mode unshaded is not optional, for the reasons above, and it takes
Godot's lighting off every surface the pack supplies a program for. Normal maps,
roughness, specular, SSAO, SDFGI and the node lights stop applying to terrain
while such a pack is running. Base-Shader recreates vanilla Minecraft's flat
look by design, which is what "vanilla-like" on its page means, so with Base
running the baked PBR pack and the lighting defaults settle nothing about what
the player sees. That is a real conflict with the sentence at the top of
docs/develop/launch-target.md, and it is a decision about what the default
should be rather than a defect to fix.
The preprocessor, which is the hard part¶
Iris rewrites pack GLSL before the driver sees it, and packs depend on that rewriting having happened. The work is bounded but it is not small:
- Legacy dialect. Packs are written against old GLSL:
attribute,varying,texture2D,gl_FragData[], fixed function matrices likegl_ModelViewMatrix. All of it has to be rewritten to something a modern compiler accepts, with the fixed function state supplied as uniforms. /* DRAWBUFFERS:0125 */and/* RENDERTARGETS: 0,1,2 */comments, which are not comments at all but the pack declaring its outputs.#definedriven feature toggles fromshaders.properties, and the option menus packs expose.#include, with its own path rules.
Iris does this with glsl-transformer, which is AGPL-3.0. Do not use it and do not read it. Write a preprocessor against the documented dialect instead: it is a rewriter over a small, well specified subset, not a general GLSL compiler, because the output only has to satisfy the driver.
That last clause holds for the screen space chain, whose output goes to
glslang. It does not hold for the gbuffers programs, whose output goes to
Godot's shading language, which needs every type written out; there the
translator is a parser and a typed emitter (see "The gbuffers translator,
as built"). Both sides run their #if and #define lines through the same
preprocessor, src/iris/iris_preproc.cpp, in two modes: conditionals only,
text left for glslang (the loader's pass over shaders.properties,
block.properties and every program), and full expansion on top of that
(the gbuffers translator). Function-like macros work in both.
On licence, corrected¶
The reasoning that a rewrite avoids adopting Iris's licence is right, but the framing is off in a way worth fixing, because this repository has got licences wrong before.
Goanna's built binary is already LGPL-3.0-or-later, because Goanna's and
Luanti's terms are both "or later" and mini-gmp is LGPL-3.0-or-later. See
THIRD-PARTY.md. So Iris's LGPL-3.0 would be compatible with the binary;
it is not the barrier.
The real reasons to write rather than copy:
- Goanna's source is LGPL-2.1-or-later. Copying LGPL-3.0 source in would make those files LGPL-3.0 and fragment the tree.
- glsl-transformer is AGPL-3.0, which is a genuine problem and the one piece to stay clear of entirely.
- Iris is Java against Minecraft's OpenGL renderer. Nothing survives the trip
to a C++ GDExtension on Vulkan anyway, which was the conclusion in
docs/systems/far-rendering.mdand holds here.
So: work from Iris's documentation and the shader pack format, which is a de facto interface specification and the part that is not the copyrightable expression. Do not work from its source. That distinction is what keeps a reimplementation a reimplementation.
Why the block map matters here¶
This is the part that is easy to miss and is the reason the texture mapping work pays for itself twice.
Since 2026-08-21 the node side of this exists: src/goanna_materials.h
builds, per session, a table of the Minecraft block each node most
resembles (through the texture map and the nodedef, one vote per tile) and
writes its index into UV2.y on every node surface, near and far. What is
still to do here is the pack side: parsing block.properties and mapping
its block names onto that table's block_names, so mc_Entity.x answers
with the pack's own numbers.
Shader packs branch on block semantics, not on block names. A pack ships a
block.properties assigning Minecraft blocks to numeric IDs, and its shaders
read that ID through mc_Entity.x to decide that this surface is leaves and
should wave, that one is water and should refract, that one emits light.
Without those IDs a pack still runs and renders flat: no waving, no water,
no emission.
Goanna needs to answer "which Minecraft block is this Luanti node" to fill
that in, which is exactly what project/texture_maps/mineclonia.csv and
kooostia16's CC0 mapping already do, from the other direction. The same
correspondence that lets a Minecraft resource pack dress a Luanti game lets
a Minecraft shader pack understand it.
Where the map is blank the node gets no ID and renders unspecialised, which is the correct failure: unremarkable rather than wrong.
Where this is most likely to fail¶
- The forward renderer mismatch. Packs are written against a deferred G buffer that Godot does not have. Rung 5 approximates it; how convincing that is across real packs is unknown and should be tested early, on rung 1, with a pack that does something visible.
- The translator is a real transpiler. Godot's shader language is GLSL
shaped but is not GLSL: no
gl_built ins, its own uniform and varying declarations, its own function set. Translating gbuffers programs is source to source work on a legacy dialect, bounded but not small. - MRT is the ceiling, per the section above.
- Pack variety. Iris compatibility in practice means a long tail of packs each depending on something slightly different. The roadmap above reaches a look; it does not reach "most packs work", and that gap is open ended.
- Performance. A pack's composite chain can be a dozen full screen passes at native resolution, on top of what Goanna already renders.