Skip to content

Swap positive and negative Z in CubemapRendererBase - #3390

Closed
ds5678 wants to merge 1 commit into
stride3d:masterfrom
ds5678:cubemap-render-z-direction
Closed

ds5678 wants to merge 1 commit into
stride3d:masterfrom
ds5678:cubemap-render-z-direction

Conversation

@ds5678

@ds5678 ds5678 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor

PR Details

I noticed this while working on an ISceneRenderer implementation for rendering cubemaps in my game.

Issue

The code for the Z direction appears to have been backwards.

Types of changes

  • Bug fix (non-breaking change which fixes an issue)

Checklist

I made the changes in github.dev. I did not test them in any way, but I'm confident that they're correct.

  • I have added tests to cover my changes.
  • All new and existing tests passed.
  • I have built and run the editor to try this change out.

@sasvdw

sasvdw commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

@ds5678 I would honestly not advise making a change like this without a full regression test.

Stride stores cubemaps with Z mirrored against world space, so the +Z face is meant to hold what the camera sees looking toward world -Z. Every consumer undoes the mirror when sampling:

The other four faces carry the same mirror. +Y uses up +Z where Direct3D wants -Z, and +X renders its image columns toward world +Z where Direct3D wants -Z. This patch makes the six face centres map as identity but leaves those axes mirrored, so front and back would swap for skybox generation, light probes and the editor cubemap preview.

Important: nothing tests this file. TestCubemapRendering.cs is excluded at csproj#L18, and LightingTests.SceneSkybox* never reaches it because its skybox asset is already a cubemap, so CubemapFromTextureRenderer is skipped. A green CI run says nothing here.

If your ISceneRenderer shows the faces backwards, I would check for the missing -z on your own sampling direction. Happy to look if you post it.

@ds5678
ds5678 marked this pull request as draft September 7, 2026 10:59
@github-actions

github-actions Bot commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

🤖 Draft PR — automatic CI is skipped to save runner minutes.

  • Mark the PR ready for review to run the full automatic CI — or add a ci-run-on-draft label to run it now without leaving draft.
  • Or arm a specific opt-in suite: ci-enduser, ci-editor, ci-ios, ci-android.

@ds5678

ds5678 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Stride stores cubemaps with Z mirrored against world space, so the +Z face is meant to hold what the camera sees looking toward world -Z. Every consumer undoes the mirror when sampling

I did not expect this. When I initially made the pull request, I hadn't finished my ISceneRenderer implementation. The lines seemed like an obvious copy-paste bug since there was no comment documenting the unintuitive behavior.

I would honestly not advise making a change like this without a full regression test.

It sounds like a much bigger endeavor than I intended. I'll probably convert this into a documentation pull request.

If your ISceneRenderer shows the faces backwards, I would check for the missing -z on your own sampling direction. Happy to look if you post it.

For reference, here is my working implementation of a ISceneRenderer class for cubemaps.

public class CubemapSceneRenderer : SceneRendererBase
{
	public int Size
	{
		get;
		set
		{
			field = value;
			if (Initialized)
			{
				MaybeReplaceTextures();
			}
		}
	} = 512;

	public PixelFormat Format
	{
		get;
		set
		{
			field = value;
			if (Initialized)
			{
				MaybeReplaceTextures();
			}
		}
	} = PixelFormat.R8G8B8A8_UNorm;
	public required ISceneRenderer ForwardRenderer { get; init; }

	public RenderGroupMask RenderMask { get; init; } = RenderGroupMask.All;

	public event Action<Texture>? TextureChanged;

	[DataMemberIgnore]
	public TransformComponent? Transform { get; set; }
	private readonly CameraComponent posXCamera;
	private readonly CameraComponent negXCamera;
	private readonly CameraComponent posYCamera;
	private readonly CameraComponent negYCamera;
	private readonly CameraComponent posZCamera;
	private readonly CameraComponent negZCamera;

#nullable disable
	private SceneExternalCameraRenderer posXCameraRenderer;
	private SceneExternalCameraRenderer negXCameraRenderer;
	private SceneExternalCameraRenderer posYCameraRenderer;
	private SceneExternalCameraRenderer negYCameraRenderer;
	private SceneExternalCameraRenderer posZCameraRenderer;
	private SceneExternalCameraRenderer negZCameraRenderer;
	private RenderTextureSceneRenderer posXTextureRenderer;
	private RenderTextureSceneRenderer negXTextureRenderer;
	private RenderTextureSceneRenderer posYTextureRenderer;
	private RenderTextureSceneRenderer negYTextureRenderer;
	private RenderTextureSceneRenderer posZTextureRenderer;
	private RenderTextureSceneRenderer negZTextureRenderer;
#nullable restore

	[DataMemberIgnore]
	public Texture Texture { get; private set; } = null!;

	public CubemapSceneRenderer()
	{
		posXCamera = CreateCamera();
		negXCamera = CreateCamera();
		posYCamera = CreateCamera();
		negYCamera = CreateCamera();
		posZCamera = CreateCamera();
		negZCamera = CreateCamera();
	}

	protected override void InitializeCore()
	{
		posXTextureRenderer = CreateRenderTextureSceneRenderer();
		negXTextureRenderer = CreateRenderTextureSceneRenderer();
		posYTextureRenderer = CreateRenderTextureSceneRenderer();
		negYTextureRenderer = CreateRenderTextureSceneRenderer();
		posZTextureRenderer = CreateRenderTextureSceneRenderer();
		negZTextureRenderer = CreateRenderTextureSceneRenderer();
		posXCameraRenderer = CreateCameraRenderer(posXCamera, posXTextureRenderer);
		negXCameraRenderer = CreateCameraRenderer(negXCamera, negXTextureRenderer);
		posYCameraRenderer = CreateCameraRenderer(posYCamera, posYTextureRenderer);
		negYCameraRenderer = CreateCameraRenderer(negYCamera, negYTextureRenderer);
		posZCameraRenderer = CreateCameraRenderer(posZCamera, posZTextureRenderer);
		negZCameraRenderer = CreateCameraRenderer(negZCamera, negZTextureRenderer);

		Texture = Texture.NewCube(GraphicsDevice, Size, Format, TextureFlags.ShaderResource | TextureFlags.RenderTarget);
	}

	protected override void CollectCore(RenderContext context)
	{
		if (Transform is null)
		{
			return;
		}

		posXCamera.ViewMatrix = Matrix.LookAtRH(Transform.Position, Transform.Position + Vector3.UnitX, Vector3.UnitY);
		negXCamera.ViewMatrix = Matrix.LookAtRH(Transform.Position, Transform.Position - Vector3.UnitX, Vector3.UnitY);

		//posYCamera.ViewMatrix = Matrix.LookAtLH(Transform.Position, Transform.Position + Vector3.UnitY, Vector3.UnitZ);
		//negYCamera.ViewMatrix = Matrix.LookAtLH(Transform.Position, Transform.Position - Vector3.UnitY, -Vector3.UnitZ);
		posYCamera.ViewMatrix = Matrix.LookAtLH(Transform.Position, Transform.Position + Vector3.UnitY, -Vector3.UnitZ);
		negYCamera.ViewMatrix = Matrix.LookAtLH(Transform.Position, Transform.Position - Vector3.UnitY, Vector3.UnitZ);

		posZCamera.ViewMatrix = Matrix.LookAtLH(Transform.Position, Transform.Position + Vector3.UnitZ, Vector3.UnitY);
		negZCamera.ViewMatrix = Matrix.LookAtLH(Transform.Position, Transform.Position - Vector3.UnitZ, Vector3.UnitY);

		posXCameraRenderer.Collect(context);
		negXCameraRenderer.Collect(context);
		posYCameraRenderer.Collect(context);
		negYCameraRenderer.Collect(context);
		posZCameraRenderer.Collect(context);
		negZCameraRenderer.Collect(context);
	}

	protected override void DrawCore(RenderContext context, RenderDrawContext drawContext)
	{
		if (Transform is null)
		{
			drawContext.CommandList.Clear(Texture, Color.Black);
			return;
		}

		posXCameraRenderer.Draw(drawContext);
		negXCameraRenderer.Draw(drawContext);
		posYCameraRenderer.Draw(drawContext);
		negYCameraRenderer.Draw(drawContext);
		posZCameraRenderer.Draw(drawContext);
		negZCameraRenderer.Draw(drawContext);

		drawContext.CommandList.CopyRegion(posXTextureRenderer.RenderTexture, 0, null, Texture, (int)CubeMapFace.PositiveX);
		drawContext.CommandList.CopyRegion(negXTextureRenderer.RenderTexture, 0, null, Texture, (int)CubeMapFace.NegativeX);

		//drawContext.CommandList.CopyRegion(posYTextureRenderer.RenderTexture, 0, null, Texture, (int)CubeMapFace.PositiveY);
		//drawContext.CommandList.CopyRegion(negYTextureRenderer.RenderTexture, 0, null, Texture, (int)CubeMapFace.NegativeY);
		drawContext.CommandList.CopyRegion(posYTextureRenderer.RenderTexture, 0, null, Texture, (int)CubeMapFace.NegativeY);
		drawContext.CommandList.CopyRegion(negYTextureRenderer.RenderTexture, 0, null, Texture, (int)CubeMapFace.PositiveY);

		drawContext.CommandList.CopyRegion(posZTextureRenderer.RenderTexture, 0, null, Texture, (int)CubeMapFace.PositiveZ);
		drawContext.CommandList.CopyRegion(negZTextureRenderer.RenderTexture, 0, null, Texture, (int)CubeMapFace.NegativeZ);
	}

	private SceneExternalCameraRenderer CreateCameraRenderer(CameraComponent camera, ISceneRenderer child)
	{
		return ToLoadAndUnload(new SceneExternalCameraRenderer()
		{
			ExternalCamera = camera,
			Child = child,
			RenderMask = RenderMask,
		});
	}

	private static CameraComponent CreateCamera()
	{
		CameraComponent result = new()
		{
			UseCustomProjectionMatrix = true,
			UseCustomViewMatrix = true,
		};
		result.ProjectionMatrix = Matrix.PerspectiveFovRH(float.DegreesToRadians(90f), 1f, result.NearClipPlane, result.FarClipPlane);
		return result;
	}

	private RenderTextureSceneRenderer CreateRenderTextureSceneRenderer()
	{
		return ToLoadAndUnload(new RenderTextureSceneRenderer()
		{
			RenderTexture = Texture.New2D(GraphicsDevice, Size, Size, Format, TextureFlags.ShaderResource | TextureFlags.RenderTarget),
			Child = ForwardRenderer,
		});
	}

	private void MaybeReplaceTextures()
	{
		posXTextureRenderer.MaybeReplaceTexture(GraphicsDevice, Size, Size, Format);
		negXTextureRenderer.MaybeReplaceTexture(GraphicsDevice, Size, Size, Format);
		posYTextureRenderer.MaybeReplaceTexture(GraphicsDevice, Size, Size, Format);
		negYTextureRenderer.MaybeReplaceTexture(GraphicsDevice, Size, Size, Format);
		posZTextureRenderer.MaybeReplaceTexture(GraphicsDevice, Size, Size, Format);
		negZTextureRenderer.MaybeReplaceTexture(GraphicsDevice, Size, Size, Format);
		if (Texture.Width != Size || Texture.Height != Size || Texture.Format != Format)
		{
			Texture original = Texture;
			Texture = Texture.NewCube(GraphicsDevice, Size, Format, TextureFlags.ShaderResource | TextureFlags.RenderTarget);
			TextureChanged?.Invoke(Texture);
			original.Dispose();
		}
	}
}
class CubemapTest : ComputeColor, Transformation, PositionStream4
{
    rgroup PerMaterial
    {
        stage TextureCube MyCubemap;
        stage SamplerState Sampler;
    }

    override float4 Compute()
    {
        float3 objectWorldPosition = mul(float4(0.0, 0.0, 0.0, 1.0), World).xyz;
        float3 pixelWorldPosition = streams.PositionWS.xyz;
        float3 pixelDirection = normalize(pixelWorldPosition - objectWorldPosition);
        pixelDirection.z = -pixelDirection.z; // Flip Z to match cubemap coordinate system
        return MyCubemap.Sample(Sampler, pixelDirection);
    }
};
image

@xen2

xen2 commented Sep 10, 2026

Copy link
Copy Markdown
Member

I reviewed everything in Stride: since we use right-handed coord system (with Z negated), everything is consistent in Stride.
SkyboxShaderCubemap, LightSkyboxShader, LightProbeShader, ComputeSphericalHarmonics all negate Z to compensate, since cubemap lookup is left-handed.

I will add a few comments in the code at specific spots (CubemapRendererBase, CubemapUtils, etc.) so that it's clearer to people who interact with cubemap.

I will close this PR as won't fix. Feel free to keep discussion if you still think something is wrong.

@xen2 xen2 closed this Sep 10, 2026
@ds5678
ds5678 deleted the cubemap-render-z-direction branch September 10, 2026 02:50
@xen2 xen2 mentioned this pull request Sep 10, 2026
4 tasks
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.

3 participants