Skip to content

Draw the end portal and end gateway surfaces - #68

Merged
sambvfx merged 3 commits into
NVIDIAGameWorks:mainfrom
JimmyfaQwQ:end-portal-surfaces
Nov 3, 2026
Merged

sambvfx merged 3 commits into
NVIDIAGameWorks:mainfrom
JimmyfaQwQ:end-portal-surfaces

Conversation

@JimmyfaQwQ

@JimmyfaQwQ JimmyfaQwQ commented Oct 28, 2026 •

Copy link
Copy Markdown
Contributor

End portals and end gateways had no surface: their render layer was dropped before capture, so the player saw straight through the frame to whatever was underneath.

Vanilla draws both with position-only vertices and rendertype_end_portal.fsh, which sums up to sixteen scaled, rotated and tinted layers of end_portal.png over end_sky.png in screen space. A path-traced hit has no screen position to project from, so this bakes the same sum into a texture laid over the world.

Layer. RenderLayerUtils maps END_PORTAL_PROGRAM and END_GATEWAY_PROGRAM (newly access-widened) to texture ids of their own, rtxremix:end_portal_surface and rtxremix:end_gateway_surface (15 and 16 layers, as in the two shader JSONs). surfaceMode() returns OPAQUE for both before classifySurfaceMode runs, since the tile is opaque everywhere and an alpha test would only cost a texture read; no SurfaceMode is added.

Bake. EndPortalSurface evaluates the shader's sum once per surface, on a worker thread, into a 2048-texel tile (16 MiB per surface) that spans two of vanilla's screen widths. For the tile to repeat without a seam, every layer has to repeat a whole number of times across it, so each layer's scale and rotation is rounded to the nearest integer lattice step. That moves a layer by up to a third of its scale (layer 15) or 14 degrees (layer 14), which is invisible in a field of random specks. The bake reads the textures the resource packs supply and is redone on resource reload; the finished pixels are dropped once uploaded. Vanilla's slow drift with the time of day is not reproduced; the tile is the pattern at time zero.

Texture coordinates. Vanilla sends none. RemixCapturingConsumer gives these quads coordinates from their position on the quad's own axis plane, offset by the block RemixBlockEntityPass passes to the provider, so the nine blocks of a portal continue one texture rather than each repeating it. The tile repeats every 48 blocks (24 blocks a screen, which matches vanilla's speck size from about eight blocks away). Vanilla normalises screen x and y separately, so its specks are 16:9 wide; vertical faces therefore repeat every 27 blocks upward, and horizontal faces stay square. Every face runs U right and V up seen from outside, so the faces of a gateway read as one pattern turned toward the viewer rather than mirror images folded over their edges. The coordinates are part of the geometry hash, so blocks at different places in the tile get distinct meshes: one per loaded portal block, nine for a 3x3 portal.

Material. Vanilla draws the surface unlit, so the albedo is a single black texel and the tile is emission only, at an intensity of 4. The surface is a black metal at full roughness: a black dielectric still reflected a few percent of the noon sky and read as a grey slab, while a metal's specular colour is its albedo. EntityTextureSet carries the emissive intensity, fallback roughness and fallback metalness, so createMaterial has no portal check. The baked texels are pulled 30% toward their own luma, because after Remix's exposure and tone mapping the dark teal came out markedly more saturated than vanilla's; at 50% the saturation matched vanilla but the brightest specks turned white. docs/RESOURCE_PACKS.md describes the bake for pack authors.

Verification

  • .\gradlew.bat test: 808 tests pass, including new EndPortalSurfaceTest (hand-computed lattices for layers 1, 2, 13 and 15, every layer within the bounds above, a seamless repeat, the summed colour and opacity, the desaturation) and RemixCapturingConsumerTest cases for projected coordinates (plane choice, continuity across blocks in both directions, one handedness around a cube's sides, reset on reinit).
  • In game (RTX 5080, low preset, 1600x900, flat test platform with a 3x3 end portal in its frame and an end gateway on obsidian). The images are the mod's own still-shot captures, so every column has the same poses; the vanilla column is the same build with path tracing off.
    • On main the portal shows the platform underneath and the gateway is empty.
    • Night: both surfaces show a dark field with specks, and the 3x3 portal has no seam between blocks. In the portal centre the field measures (3, 13, 23) against vanilla's (5, 14, 23), and the median speck size is similar to vanilla's.
    • Noon: the field is near black, with no grey sheen.
main This change Vanilla (/rtx off)
Portal, night before_portal_night after_portal_night vanilla_portal_night
Gateway, night before_gateway_night after_gateway_night vanilla_gateway_night
Portal, noon before_portal_noon after_portal_noon vanilla_portal_noon

Known differences

  • Brightness and colour pass through Remix's auto-exposure and local tone mapping. The brightest specks come out brighter and slightly more saturated than vanilla's, and at noon the specks dim to near invisible where vanilla still shows them. It is tuned for night, strongholds and the End, where portals occur in survival.
  • Speck edges are softer than vanilla's crisp pixels, likely from DLSS-RR reconstruction at the low preset and bloom at night; not isolated.
  • The pattern is fixed to the world: its scale matches vanilla's only at about eight blocks, it does not stay put on screen, its layers are rotated relative to the world rather than the screen, and it does not drift with time.
  • Edges between faces still show a seam.

Not in this change. Vanilla's end portal and end gateway are light level 15; neither emits a Remix light here. A follow-up PR plans to draw the surfaces with vanilla's own shader over the composited image after tone mapping, the way RemixWeatherRenderer draws rain and snow against Remix's interop depth, while keeping a dark surface in the path-traced scene for reflections, and to add portal and gateway lights. That would match vanilla exactly in the primary view.

🤖 Generated with Claude Code

End portals and end gateways had no surface: the layer was dropped
before capture, so the player saw straight through the frame.

Vanilla draws both with position-only vertices and a shader that sums
up to sixteen scaled, rotated and tinted layers of end_portal.png over
end_sky.png in screen space. RenderLayerUtils now maps the two shader
programs to texture ids of their own, and EndPortalSurface bakes the
same sum once, on a worker thread, into a 1024-texel tile laid over
eight blocks. Each layer's scale and rotation is rounded to an integer
lattice step over the tile so the tile repeats without a seam; no
layer moves by more than a third of its scale or 15 degrees. The bake
reads the textures the resource packs supply and is redone on reload.
Vanilla's slow drift with the time of day is not reproduced.

The capturing consumer gives those quads texture coordinates from
their position on the quad's own axis plane, offset by the block the
provider was told about, so neighbouring portal blocks continue one
texture. The tile is the material's albedo and its emission, at an
intensity of 10 rather than the entity default of 100, which turned
the whole surface pale blue under a dark scene's exposure.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Junyu Tao <jimmyfa@jimmyfa.xyz>
@JimmyfaQwQ
JimmyfaQwQ requested a review from sambvfx as a code owner October 28, 2026 15:58

@sambvfx sambvfx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for this, Jimmy. The bake is a neat way around a shader that only works in screen space, and rounding the layer lattices so the tile stays seamless is clever and well explained.

A few things before merge:

  • Material look: the tile is the albedo as well as the emission, so the scene lights it. In your noon shot it reads as a grey slab, and at night as bright cyan, where vanilla stays a dark void with specks. Could you try a black albedo with the tile only as emission, retune EMISSIVE_INTENSITY, and add a vanilla /rtx off shot from the same spot to compare?
  • Surface mode: the tile is opaque everywhere, so mapping the two programs to OPAQUE rather than CUTOUT skips the alpha test. Heads-up: #66 also changes classifySurfaceMode, so whichever lands second will need a small rebase.
  • Docs: a sentence in docs/RESOURCE_PACKS.md under "Entities And Runtime Atlases" would help pack authors. It should say the portal and gateway are baked from end_portal.png and end_sky.png, take no sidecars, and emit at 10.

Smaller things are inline. None of them block.

textures.destroy();
return null;
}
textures.emissive = textures.albedo;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is what makes the surface pick up sunlight. Vanilla draws it unlit, so I think a black or dimmed albedo with the tile as emission only would match better across day and night.

materialBuilder.emissiveTexture(emissive.getHandle())
.emissiveIntensity(EMISSIVE_INTENSITY);
.emissiveIntensity(EndPortalSurface.isSurfaceTexture(key.textureId())
? EndPortalSurface.EMISSIVE_INTENSITY : EMISSIVE_INTENSITY);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small one: could EntityTextureSet carry its own emissive intensity, set in createEndPortalTextureSet? That keeps the texture-id check out of createMaterial.

RenderLayer.MultiPhaseParameters phases = multiPhase.getPhases();
// The end portal and gateway programs combine two textures in screen space
// and their vertices carry position only; they draw with a baked texture.
if (phases.program == RenderPhase.END_PORTAL_PROGRAM) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These layers end up CUTOUT, so the material is alpha-tested even though the tile is fully opaque. OPAQUE would avoid that.

* on worker threads. A reload drops them all, so a bake that finishes for the
* previous packs is never read.
*/
private final Map<Identifier, CompletableFuture<byte[]>> bakes = new HashMap<>();

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

reloadMaterials() clears this map and textureSets together, so the finished pixels are never reused once uploaded. Dropping the entry after poll returns them saves about 8 MiB of heap.

* <p>For the tile to repeat without a seam, every layer must itself repeat a whole
* number of times across it. Vanilla's layers are rotated by arbitrary angles, so
* each layer's scale and rotation is rounded to the nearest integer lattice step
* over the tile; a layer of a random speckle texture that comes out a few percent

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

"A few percent" undersells layer 15. It rotates by a whole number of turns, and its 1.5 scale rounds to (2, 0), which is 33% larger. Layers 9, 11 and 13 are around 10%. Maybe just say "up to a third", to match the test.

return (unorm(r) << 16) | (unorm(g) << 8) | unorm(b);
}

private static int sample(Image image, double u, double v) {

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: layer 1 repeats every ~120 tile texels against the 256-px texture, so nearest sampling skips a lot of single-texel specks. A 2×2 supersample per output texel would keep more of them.

*/
static int[] layerLattice(int layer) {
double scale = (4.5 - layer / 4.0) * SCREENS_PER_TILE;
double angle = Math.toRadians((layer * layer * 4321.0 + layer * 9.0) * 2.0);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Optional: reducing (l*l*4321 + 9*l) * 2 mod 360 in integer math before toRadians keeps the rounding exact, rather than relying on cos of a ~2M-degree angle.

/**
* Sets the block whose local space the next captures are in. Only surfaces whose
* texture coordinates are projected from position read it, so neighbouring end
* portal blocks continue one texture; a capture that never sets it projects as if

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The origin isn't reset in beginEntity(), so a capture that never calls this gets the previous block entity's origin, not (0, 0, 0). Zeroing it there would make this comment true.

void everyLayerLatticeStaysCloseToVanillasScaleAndRotation() {
for (int layer = 1; layer <= EndPortalSurface.GATEWAY_LAYERS; layer++) {
int[] lattice = EndPortalSurface.layerLattice(layer);
double scale = (4.5 - layer / 4.0) * EndPortalSurface.SCREENS_PER_TILE;

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This recomputes the same formula as layerLattice, so a misread of the shader would still pass. Could you pin a few literal lattices instead, for example layer 1 → (8, 3), layer 2 → (6, 6), layer 15 → (2, 0)?

assertEquals(4 * 4 * 4, rgba.length);
for (int i = 0; i < rgba.length; i += 4) {
assertEquals(82, rgba[i] & 0xFF);
assertEquals(150, rgba[i + 1] & 0xFF);

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny: blue isn't asserted.

JimmyfaQwQ and others added 2 commits November 3, 2026 21:54
The baked tile was the surfaces' albedo as well as their emission, so
scene light lit them: a grey slab at noon and bright cyan at night,
where vanilla draws a dark field with specks. The albedo is now one
black texel and the tile emission only, at an intensity of 3, which
keeps the night field about as dark against lit stone as vanilla's.
Their roughness is 1, because at 0.7 the black surface still mirrored
the noon sky as a grey sheen. Noon exposure still hides the specks.

Both programs now map to OPAQUE: the tile is opaque everywhere, so the
cutout alpha test only cost a texture read per hit. EntityTextureSet
carries its emissive intensity and fallback roughness, so
createMaterial no longer checks for the portal texture ids.

EndPortalSurface forgets a finished bake once it hands the tile over,
which frees about 8 MiB of heap, and reduces each layer's angle mod 360
in integer math before converting it. beginEntity() zeroes the block
origin, so a capture that never sets one projects from the origin as
documented. The lattice test pins hand-computed steps instead of
recomputing the formula, and the bake test checks blue.
RESOURCE_PACKS.md describes the bake for pack authors.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Junyu Tao <jimmyfa@jimmyfa.xyz>
At four blocks a screen the specks came out a small fraction of
vanilla's size and read as tinted noise. Vanilla's pattern spans one
screen whatever the distance, so no world scale matches it everywhere.
The tile now repeats every 48 blocks, 24 blocks a screen, which matches
vanilla's speck size from about eight blocks away. Vanilla normalises
screen x and y separately, so its specks are 16:9 wide; vertical faces
repeat every 27 blocks upward to match, and horizontal faces stay
square. The tile grows to 2048 texels so that layer 1's single-texel
specks still land on texels of their own.

Opposite faces projected the same axes the same way round, so adjacent
faces of an end gateway were mirror images folded over their shared
edge. Every face now runs U right and V up seen from outside, chosen by
which side of the block's middle the face sits on.

A black dielectric still reflected a few percent of the noon sky, which
read as a grey slab. The surface is now a black metal, whose specular
colour is its albedo, at full roughness; EntityTextureSet carries the
fallback metalness. Emission rises to 4, where the night field is about
as dark as vanilla's.

After Remix's exposure and tone mapping the dark teal came out markedly
more saturated than vanilla's, so the baked texels are pulled 30%
toward their own luma. Half matched vanilla's saturation but turned the
brightest specks white.

RESOURCE_PACKS.md gives the new material. Tests cover the
desaturation and one handedness around a cube's sides.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Junyu Tao <jimmyfa@jimmyfa.xyz>
@JimmyfaQwQ

Copy link
Copy Markdown
Contributor Author

Thanks for this, Jimmy. The bake is a neat way around a shader that only works in screen space, and rounding the layer lattices so the tile stays seamless is clever and well explained.

A few things before merge:

  • Material look: the tile is the albedo as well as the emission, so the scene lights it. In your noon shot it reads as a grey slab, and at night as bright cyan, where vanilla stays a dark void with specks. Could you try a black albedo with the tile only as emission, retune EMISSIVE_INTENSITY, and add a vanilla /rtx off shot from the same spot to compare?
  • Surface mode: the tile is opaque everywhere, so mapping the two programs to OPAQUE rather than CUTOUT skips the alpha test. Heads-up: Light the vanilla beacon and its beam #66 also changes classifySurfaceMode, so whichever lands second will need a small rebase.
  • Docs: a sentence in docs/RESOURCE_PACKS.md under "Entities And Runtime Atlases" would help pack authors. It should say the portal and gateway are baked from end_portal.png and end_sky.png, take no sidecars, and emit at 10.

Smaller things are inline. None of them block.

Thanks for the careful review. Everything is addressed in two commits on top: 947f5f6 for the points you raised, and a second one after comparing against vanilla shots, which changed the scale and the material again. The description has the new three-column table with a vanilla /rtx off column, same poses throughout.

  • Material: the albedo is a single black texel and the tile is emission only. Black albedo alone left a grey slab at noon, because a black dielectric still reflects a few percent specularly, so the surface is now a black metal (whose specular colour is its albedo) at roughness 1. Emission ended at 4 rather than 10: at 10 the night field glowed cyan, at 4 the portal centre measures (3, 13, 23) against vanilla's (5, 14, 23). The baked texels are also pulled 30% toward their luma, because after Remix's exposure and tone mapping the dark teal came out more saturated than vanilla's.
  • Surface mode: surfaceMode() returns OPAQUE for both programs before classifySurfaceMode runs, so its signature is unchanged and the overlap with Light the vanilla beacon and its beam #66 should stay small.
  • Docs: added the paragraph under "Entities And Runtime Atlases". It says the surfaces reflect no light and emit at 4.
  • EntityTextureSet: it carries its emissive intensity, plus fallback roughness and metalness, all set in createEndPortalTextureSet, so createMaterial has no texture-id check.
  • OPAQUE inline: as above.
  • poll heap: poll drops the entry once it hands the tile over.
  • "A few percent": the class comment now says up to a third of the scale (layer 15) and 14 degrees (layer 14).
  • Integer mod 360: done before toRadians.
  • beginEntity() origin: zeroed there now.
  • Literal lattices: the test pins layers 1, 2 and 15 to your values, plus 13 at (-2, -1) for the negative rounding.
  • Blue: asserted.
  • 2x2 supersample: I tried it and the captures were indistinguishable from single sampling, so I left it out. The tile is now 2048 texels for other reasons, which puts layer 1's repeats close to one tile texel per texture texel anyway.

What changed beyond your points: at the old 8-block tile the specks were a small fraction of vanilla's size and read as noise, so the tile now repeats every 48 blocks (27 upward on vertical faces, for vanilla's 16:9 speck shape), which matches vanilla's speck size from about eight blocks away. Every face now has the same handedness seen from outside, so a gateway's sides no longer mirror each other across their edges.

What still differs: everything passes through Remix's exposure and tone mapping, so the brightest specks are a little brighter and more saturated than vanilla's and the specks fade at noon; the pattern is fixed to the world, so it matches vanilla's scale at one distance only and does not drift. I would like to close that in a follow-up PR by drawing the surfaces with vanilla's own shader over the composited image after tone mapping, the way rain and snow are drawn against the interop depth, while keeping a dark surface in the path-traced scene for reflections, together with portal and gateway lights.

@sambvfx sambvfx left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, Jimmy. This is a really careful second pass. The black metal at full roughness is a neat answer to the grey sheen, and the comparison table with the vanilla column makes the result easy to judge. I checked each point against the new commits:

  • The albedo is a black texel with the tile as emission only. Intensity, roughness and metalness come from EntityTextureSet, so createMaterial no longer checks the texture id.
  • surfaceMode() returns OPAQUE for both programs ahead of classifySurfaceMode. It merges cleanly with #66.
  • poll drops the entry, and beginEntity zeroes the origin.
  • The angle is reduced mod 360 in integer math, and the class comment gives the real worst cases.
  • The literal lattices, including layer 13's negative rounding, and blue are now asserted.
  • The RESOURCE_PACKS.md paragraph is in.

I merged it with current main locally. The end portal, capturing consumer and render layer tests pass, and so does the Fabric suite. CI is green too: the forge job's earlier failure was a Maven download, and it passed on rerun.

I also checked it in game on top of main, at noon, at night and after an F3+T resource reload. Both surfaces look right and come back after the reload, with nothing in the log.

For the record: a real-size bake now takes about 0.9 s per surface here, against about 0.2 s at 1024, and each tile is 16 MB. It runs once, off the render thread, so I'm fine with it for the extra speck detail.

On drawing the surfaces with vanilla's shader over the tone-mapped image: it's an interesting idea, but it interacts with upscaling, frame generation and the reflections, so please open an issue to talk it through before starting on a PR.

Approving. Thanks again.

@sambvfx
sambvfx merged commit 0dcc57a into NVIDIAGameWorks:main Nov 3, 2026
6 of 7 checks passed
AlexMelanFromRingo added a commit to AlexMelanFromRingo/rtx-remix-minecraft that referenced this pull request Nov 3, 2026
Brings in the end portal and gateway surfaces (NVIDIAGameWorks#68) and the lighting
profile upgrade (NVIDIAGameWorks#57). NVIDIAGameWorks#68's code uses Mojang names: the end portal and
gateway programs are RenderStateShard.RENDERTYPE_END_PORTAL_SHADER and
RENDERTYPE_END_GATEWAY_SHADER, compared against CompositeState.shaderState,
the bake reads textures through Resource.open and
NativeImage.getPixelRGBA, and its tests draw with addVertex and setUv.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Alex Melan <59925151+AlexMelanFromRingo@users.noreply.github.com>
AlexMelanFromRingo added a commit to AlexMelanFromRingo/rtx-remix-minecraft that referenced this pull request Nov 3, 2026
Brings in the end portal and gateway surfaces (NVIDIAGameWorks#68) and the lighting
profile upgrade (NVIDIAGameWorks#57) through NVIDIAGameWorks#78. On 26.3 the surfaces are recognised by
RenderPipelines.END_PORTAL and END_GATEWAY, which need no access widener;
the bake reads end_portal.png from its 26.x folder, textures/entity/end_portal/,
and takes NativeImage.getPixel's ARGB. The payload history records the 26.3
minecraft-logic README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Alex Melan <59925151+AlexMelanFromRingo@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants