Schedule Eve world effects through URP

This commit is contained in:
Meta Vens 2026-07-18 16:36:28 +01:00
commit b8259a8e52
8 changed files with 137 additions and 22 deletions

View file

@ -15,6 +15,14 @@ selection filters; they are not product knowledge required by the client.
The package never imports provider product assemblies.
Add `EveUnityRendererFeature` to the Universal Renderer used by Eve cameras.
The feature is the single URP-owned scheduling point for provider-advertised
volume, particle, and exposure programs. The lowerers use Unity 6 RenderGraph
resource declarations; effects that sample camera color request an intermediate
color texture and never read the back buffer. Do not schedule these effects from
`RenderPipelineManager` callbacks. The provider owns programs, assets, and
parameters; the Universal Renderer owns when and where they execute.
Advertised input bindings are performed from the provider's semantic action
catalog. Direct and chord gestures submit once per press edge; held values
submit their advertised press/release scalar. `view-direction.v1` samples the

View file

@ -8,7 +8,7 @@ using UnityEngine.Rendering.Universal;
namespace GameCult.Eve.UnityScene
{
public sealed class EveUnityAdaptiveExposureRenderer : MonoBehaviour
public sealed class EveUnityAdaptiveExposureRenderer : MonoBehaviour, IEveUnityRenderPassSource
{
private const int HistogramBins = 128;
private ComputeShader? _compute;
@ -32,6 +32,7 @@ namespace GameCult.Eve.UnityScene
public bool IsConfigured { get; private set; }
public static RenderPassEvent ExposureRenderPassEvent =>
(RenderPassEvent)((int)RenderPassEvent.BeforeRenderingPostProcessing + 1);
int IEveUnityRenderPassSource.RenderOrder => 300;
public void Configure(
float lowPercent,
@ -58,22 +59,23 @@ namespace GameCult.Eve.UnityScene
private void OnEnable()
{
_camera = GetComponent<Camera>();
RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
EveUnityRenderPassRegistry.Register(this);
}
private void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
EveUnityRenderPassRegistry.Unregister(this);
ReleaseResources();
}
private void OnDestroy() => ReleaseResources();
private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
bool IEveUnityRenderPassSource.TryEnqueuePass(Camera camera, ScriptableRenderer renderer)
{
if (!IsConfigured || _camera == null || camera != _camera) return;
if (!IsConfigured || _camera == null || camera != _camera) return false;
_pass ??= new AdaptiveExposurePass(this);
camera.GetUniversalAdditionalCameraData().scriptableRenderer.EnqueuePass(_pass);
renderer.EnqueuePass(_pass);
return true;
}
private bool EnsureResources()
@ -195,6 +197,9 @@ namespace GameCult.Eve.UnityScene
{
_owner = owner;
renderPassEvent = ExposureRenderPassEvent;
// Histogram construction samples the active camera color. URP must therefore
// provide an intermediate color texture instead of exposing the back buffer.
requiresIntermediateTexture = true;
}
public override void RecordRenderGraph(RenderGraph renderGraph, ContextContainer frameData)
@ -202,6 +207,7 @@ namespace GameCult.Eve.UnityScene
var cameraData = frameData.Get<UniversalCameraData>();
if (cameraData.camera == null || cameraData.camera != _owner._camera) return;
var resourceData = frameData.Get<UniversalResourceData>();
if (resourceData.isActiveTargetBackBuffer) return;
var colorTarget = resourceData.activeColorTexture;
if (!colorTarget.IsValid()) return;

View file

@ -0,0 +1,59 @@
using System;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Rendering.Universal;
#nullable enable
namespace GameCult.Eve.UnityScene
{
internal interface IEveUnityRenderPassSource
{
int RenderOrder { get; }
bool TryEnqueuePass(Camera camera, ScriptableRenderer renderer);
}
internal static class EveUnityRenderPassRegistry
{
private static readonly List<IEveUnityRenderPassSource> Sources =
new List<IEveUnityRenderPassSource>();
[RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.SubsystemRegistration)]
private static void Reset() => Sources.Clear();
internal static void Register(IEveUnityRenderPassSource source)
{
if (source == null) throw new ArgumentNullException(nameof(source));
if (Sources.Contains(source)) return;
Sources.Add(source);
Sources.Sort(static (left, right) => left.RenderOrder.CompareTo(right.RenderOrder));
}
internal static void Unregister(IEveUnityRenderPassSource source) => Sources.Remove(source);
internal static void Enqueue(Camera camera, ScriptableRenderer renderer)
{
for (var index = 0; index < Sources.Count; index++)
Sources[index].TryEnqueuePass(camera, renderer);
}
}
/// <summary>
/// URP-owned scheduling point for generic Eve world effects.
/// Add this feature to the Universal Renderer used by Eve cameras.
/// Provider surfaces supply effect programs, assets, and parameters; this feature only lowers them.
/// </summary>
public sealed class EveUnityRendererFeature : ScriptableRendererFeature
{
public override void Create()
{
}
public override void AddRenderPasses(ScriptableRenderer renderer, ref RenderingData renderingData)
{
var camera = renderingData.cameraData.camera;
if (camera == null || renderingData.cameraData.cameraType != CameraType.Game) return;
EveUnityRenderPassRegistry.Enqueue(camera, renderer);
}
}
}

View file

@ -0,0 +1,11 @@
fileFormatVersion: 2
guid: 340385338d304b5aaf069562d4e7b457
MonoImporter:
externalObjects: {}
serializedVersion: 2
defaultReferences: []
executionOrder: 0
icon: {instanceID: 0}
userData:
assetBundleName:
assetBundleVariant:

View file

@ -12,7 +12,7 @@ using UnityEngine.Rendering.Universal;
namespace GameCult.Eve.UnityScene.Fields
{
public sealed class EveUnityFieldsParticleRenderer : MonoBehaviour
public sealed class EveUnityFieldsParticleRenderer : MonoBehaviour, IEveUnityRenderPassSource
{
private EveUnityPlayableWorldClientHost? _host;
private IEveUnityFieldsSplatsDocumentSource? _source;
@ -42,6 +42,7 @@ namespace GameCult.Eve.UnityScene.Fields
public Vector2 LastGridCenter { get; private set; }
public bool ProgramReady => _compute != null && _material != null && _particles != null && _quadPoints != null;
public static RenderPassEvent CompositionRenderPassEvent => RenderPassEvent.BeforeRenderingPostProcessing;
int IEveUnityRenderPassSource.RenderOrder => 200;
public void Bind(EveUnityPlayableWorldClientHost host, IEveUnityFieldsSplatsDocumentSource? source)
{
@ -53,11 +54,11 @@ namespace GameCult.Eve.UnityScene.Fields
enabled = _source != null;
}
private void OnEnable() => RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
private void OnEnable() => EveUnityRenderPassRegistry.Register(this);
private void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
EveUnityRenderPassRegistry.Unregister(this);
ReleaseState();
}
@ -75,15 +76,16 @@ namespace GameCult.Eve.UnityScene.Fields
PresentedFrameId = document.FrameId;
}
private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
bool IEveUnityRenderPassSource.TryEnqueuePass(Camera camera, ScriptableRenderer renderer)
{
if (_host == null || _document == null || camera == null ||
_host.ActiveCameraTransform != camera.transform)
return;
return false;
var field = ActiveField();
if (field == null || !PrepareFrame(field, camera, _document)) return;
if (field == null || !PrepareFrame(field, camera, _document)) return false;
_renderPass ??= new EveUnityFieldsParticlePass(this);
camera.GetUniversalAdditionalCameraData().scriptableRenderer.EnqueuePass(_renderPass);
renderer.EnqueuePass(_renderPass);
return true;
}
private EveUnityFieldParticlesProjection? ActiveField() =>
@ -488,7 +490,7 @@ namespace GameCult.Eve.UnityScene.Fields
data.Owner = _owner;
data.ColorTarget = resources.activeColorTexture;
data.DepthTarget = resources.activeDepthTexture;
builder.UseTexture(data.ColorTarget, AccessFlags.ReadWrite);
builder.UseTexture(data.ColorTarget, AccessFlags.Write);
if (data.DepthTarget.IsValid()) builder.UseTexture(data.DepthTarget, AccessFlags.Read);
builder.AllowPassCulling(false);
builder.SetRenderFunc(static (PassData pass, UnsafeGraphContext context) =>

View file

@ -12,7 +12,7 @@ using UnityEngine.Rendering.Universal;
namespace GameCult.Eve.UnityScene.Fields
{
public sealed class EveUnityFieldsVolumeRenderer : MonoBehaviour
public sealed class EveUnityFieldsVolumeRenderer : MonoBehaviour, IEveUnityRenderPassSource
{
private EveUnityPlayableWorldClientHost? _host;
private IEveUnityFieldsSplatsDocumentSource? _source;
@ -40,6 +40,7 @@ namespace GameCult.Eve.UnityScene.Fields
public bool UsesTemporalHistory => HasTemporalProgram();
public Vector2 LastGridCenter { get; private set; }
public static RenderPassEvent CompositionRenderPassEvent => RenderPassEvent.BeforeRenderingPostProcessing;
int IEveUnityRenderPassSource.RenderOrder => 100;
public void Bind(EveUnityPlayableWorldClientHost host, IEveUnityFieldsSplatsDocumentSource? source)
{
@ -51,11 +52,11 @@ namespace GameCult.Eve.UnityScene.Fields
enabled = _source != null;
}
private void OnEnable() => RenderPipelineManager.beginCameraRendering += OnBeginCameraRendering;
private void OnEnable() => EveUnityRenderPassRegistry.Register(this);
private void OnDisable()
{
RenderPipelineManager.beginCameraRendering -= OnBeginCameraRendering;
EveUnityRenderPassRegistry.Unregister(this);
ReleaseRenderState();
}
@ -114,13 +115,14 @@ namespace GameCult.Eve.UnityScene.Fields
return true;
}
private void OnBeginCameraRendering(ScriptableRenderContext context, Camera camera)
bool IEveUnityRenderPassSource.TryEnqueuePass(Camera camera, ScriptableRenderer renderer)
{
if (_host == null || _document == null || camera == null ||
_host.ActiveCameraTransform != camera.transform)
return;
return false;
_renderPass ??= new EveUnityFieldsVolumePass(this);
camera.GetUniversalAdditionalCameraData().scriptableRenderer.EnqueuePass(_renderPass);
renderer.EnqueuePass(_renderPass);
return true;
}
private void RenderVolume(
@ -478,7 +480,7 @@ namespace GameCult.Eve.UnityScene.Fields
passData.ColorTarget = colorTarget;
passData.DepthTarget = resourceData.activeDepthTexture;
builder.UseTexture(passData.ColorTarget, AccessFlags.ReadWrite);
builder.UseTexture(passData.ColorTarget, AccessFlags.Write);
if (passData.DepthTarget.IsValid())
builder.UseTexture(passData.DepthTarget, AccessFlags.Read);
builder.AllowPassCulling(false);

View file

@ -155,6 +155,15 @@ namespace GameCult.Eve.UnityScene.Tests
Assert.That(typeof(IEveUnityFieldsSplatsDocumentSource).GetEvent("FieldsSplatsAvailable"), Is.Not.Null);
}
[Test]
public void UrpRendererFeatureOwnsGenericWorldEffectScheduling()
{
Assert.That(typeof(EveUnityRendererFeature).IsSubclassOf(typeof(ScriptableRendererFeature)), Is.True);
AssertRenderOrder(typeof(EveUnityFieldsVolumeRenderer), 100);
AssertRenderOrder(typeof(EveUnityFieldsParticleRenderer), 200);
AssertRenderOrder(typeof(EveUnityAdaptiveExposureRenderer), 300);
}
[Test]
public void VolumeRendererCompositesBeforeProviderPostProcessing()
{
@ -163,6 +172,24 @@ namespace GameCult.Eve.UnityScene.Tests
Is.EqualTo(RenderPassEvent.BeforeRenderingPostProcessing));
}
private static void AssertRenderOrder(Type sourceType, int expected)
{
var schedulingInterface = sourceType.GetInterfaces().Single(type =>
string.Equals(type.Name, "IEveUnityRenderPassSource", StringComparison.Ordinal));
var property = schedulingInterface.GetProperty("RenderOrder");
Assert.That(property, Is.Not.Null);
var gameObject = new GameObject("eve-render-pass-order-test");
try
{
var component = gameObject.AddComponent(sourceType);
Assert.That(property!.GetValue(component), Is.EqualTo(expected));
}
finally
{
UnityEngine.Object.DestroyImmediate(gameObject);
}
}
[Test]
public void ParticleRendererExecutesProviderProgramWithoutProviderCode()
{

View file

@ -1,7 +1,7 @@
{
"name": "org.gamecult.eve.unity-scene",
"displayName": "Eve Unity Scene Lowering",
"version": "0.3.65",
"version": "0.3.66",
"unity": "6000.0",
"description": "Provider-agnostic Unity scene shell for gamecult.eve.surface.v1 interactive world surfaces.",
"dependencies": {