Presentationcom.zoa.rendering.core · v0.1.0

ZOA Rendering Core

Pipeline-neutral rendering contracts and the adapter registry that URP and HDRP plug into.

Rendering Core exists so that gameplay code never names a shader. A weapon that wants its primary metal surface asks for the semantic id "weapon.metal.primary" and gets back whatever the active pipeline decided that means. Swapping a project from URP to HDRP changes which adapter is registered, not a single call site in gameplay.

The package contains contracts, value types, a registry, and a validator. It contains no pipeline code at all: there is no reference to URP or HDRP anywhere in it, and the two adapter packages depend on this one, never the reverse. With the dependency pointing that way, the package compiles and unit-tests in a project that has neither pipeline installed.

A second, smaller system lives here for the same reason. The visual environment layer describes a scene's ambient light, fog, exposure, and skybox as pipeline-neutral intent, applies the part that both pipelines honour through Unity's RenderSettings, and hands the pipeline-specific remainder to registered applier hooks. Scene seeds and atmosphere systems can author a mood without knowing which renderer will draw it.

How it works

Concepts

The adapter pattern, in one registry

IRenderPipelineAdapter is the whole pipeline abstraction, and it has four members: a PipelineId string, a DisplayName, an IsAvailable flag, and a MaterialResolver. UrpRenderPipelineAdapter reports "urp" and hands back a UrpMaterialVariantResolver; HdrpRenderPipelineAdapter reports "hdrp" and hands back an HdrpMaterialVariantResolver. Neither one is referenced from this package.

RenderPipelineAdapterRegistry is the meeting point. It is a static, case-insensitive dictionary keyed by PipelineId, with a separately tracked active id. A pipeline package registers its adapter at setup time, someone calls SetActive with the id, and every consumer from then on goes through GetActive. TryGet remains available for the rarer case where a tool wants to inspect a non-active adapter, so an editor window can compare URP and HDRP mappings side by side.

Because the registry is static it survives scene loads but not a domain reload. Register during bootstrap or from an editor setup path rather than assuming a previous session left an adapter behind.

Semantic ids and resolved results

MaterialSemanticId, FxSemanticId, and VisualProfileId are all the same shape: a readonly struct wrapping a single string, with ordinal equality, an IsValid check for null-or-whitespace, and equality operators. A string rather than an enum means a project adds "vehicle.paint.metallic" without recompiling the package, and the ids read the same way in an asset, a log line, and a debugger.

Resolution returns a small result object rather than a Unity Material. ResolvedMaterial carries the SemanticId it answered, an AssetPath, and the RenderPipelineTarget that produced it. ResolvedFx is the same shape for effects. Handing back a path keeps both resolvers in engine-free assemblies, unit-testable without a graphics device. The caller decides whether to Resources.Load it at runtime or AssetDatabase-load it in a tool.

The TryResolve pattern is used throughout, and a miss is a legitimate answer. Both resolvers return false for an invalid id and for an id nobody mapped, so a caller can fall back instead of defending against an exception.

Visual environment: intent applied in two halves

VisualEnvironmentState is an engine-free struct describing what a scene should look like. Colours are plain rgba floats rather than UnityEngine.Color so the model and its tests stay free of the engine. Each section is gated by a Has flag, which makes partial application explicit: a profile that only authors fog leaves ambient, exposure, and skybox untouched instead of stamping defaults over them.

RenderSettingsVisualEnvironmentService applies the pipeline-agnostic half through RenderSettings, covering ambient mode and colour, all three fog modes, and the skybox. Then it walks its registered IVisualEnvironmentApplier hooks in registration order, each of which owns a pipeline-specific slice. An applier that throws is caught and logged as a warning; the remaining appliers still run.

Exposure is the case worth understanding. With no pipeline hook registered, an ExposureEv request is recorded in Current and left unapplied. The service never approximates a pipeline feature it cannot drive, and the URP and HDRP packages each ship the applier that makes exposure real.

The skybox is resolved semantically, never by direct material reference. ApplySkybox takes the state's SkyboxSemanticId to the active adapter's material resolver and then loads the returned path through Resources. A missing adapter, an unmapped id, or a path that is not Resources-loadable each produce a warning and leave the skybox unchanged.

Self-registration that a host can pre-empt

VisualEnvironmentBootstrap registers the default service at RuntimeInitializeLoadType.BeforeSceneLoad, but only when nothing has already claimed the contract. Ensure is idempotent and public, so a consumer that needs the service before scene load can call it directly instead of racing the attribute.

A project that wants its own IVisualEnvironmentService registers it earlier, from a scene installer with a negative DefaultExecutionOrder, and the bootstrap defers. This is the same self-ensure idiom the rest of Foundry uses: a default that steps aside for an explicit override.

In the editor

Screens

Screenshot pending

/screenshots/rendering-core-environment-apply.png

Two matched captures of one scene from an identical camera position: the first with default ambient and no fog, the second after applying a state with exponential fog, cooled ambient, and a negative exposure offset, so the Has-flag partial application reads visually.

The same scene before and after a VisualEnvironmentState is applied.

Setup

Workflow

  1. 01

    Install exactly one adapter package

    Rendering Core resolves nothing on its own. Add com.zoa.rendering.urp or com.zoa.rendering.hdrp alongside it. The product ships no built-in renderer support, so the choice is between the two scriptable pipelines.

  2. 02

    Register and activate the adapter

    Call RenderPipelineAdapterRegistry.Register with the adapter instance, then SetActive with its PipelineId. Both pipeline packages expose a Workbench setup wizard whose first step does the registration for you; a runtime bootstrap does the same two calls by hand.

  3. 03

    Ask for semantic ids from gameplay

    Resolve the active adapter once and cache the MaterialResolver, then call TryResolve with the ids your content uses. Treat a false return as normal and give the call site a fallback; content routinely asks for ids nobody mapped.

  4. 04

    Validate before you ship a scene

    Run RenderingValidator.ValidateAll during bootstrap or from a build step. The three diagnostics it produces cover the mistakes that otherwise appear as silently missing materials: nothing registered, nothing activated, and an active adapter that reports itself unavailable.

  5. 05

    Drive scene mood through the environment service

    Resolve IVisualEnvironmentService, build a VisualEnvironmentState with only the Has flags you mean, and call Apply. Subscribe to Applied when a system needs to react to environment changes, and register an applier when a project has pipeline-specific work of its own.

Surface

Key types

IRenderPipelineAdapter

interface

The pipeline abstraction. Implemented once per pipeline in com.zoa.rendering.urp and com.zoa.rendering.hdrp, never in this package.

  • string PipelineId { get; }
  • string DisplayName { get; }
  • bool IsAvailable { get; }
  • IMaterialVariantResolver MaterialResolver { get; }

RenderPipelineAdapterRegistry

service

Static, case-insensitive registry of adapters plus the active-pipeline pointer. The single place a consumer asks who is rendering right now.

  • static void Register(IRenderPipelineAdapter adapter)
  • static bool Unregister(string pipelineId)
  • static void SetActive(string pipelineId)
  • static IRenderPipelineAdapter GetActive()
  • static bool TryGet(string pipelineId, out IRenderPipelineAdapter adapter)
  • static IReadOnlyCollection<IRenderPipelineAdapter> All { get; }
  • static void ClearAll()

IMaterialVariantResolver

interface

Maps a MaterialSemanticId to a pipeline-specific material. Returns false rather than throwing when the id is invalid or unmapped.

  • bool TryResolve(MaterialSemanticId semanticId, out ResolvedMaterial result)
  • IReadOnlyList<MaterialSemanticId> RegisteredIds { get; }

IFxResolver

interface

The effect-side twin of the material resolver. Maps an FxSemanticId to a pipeline-specific effect asset.

  • bool TryResolve(FxSemanticId semanticId, out ResolvedFx result)
  • IReadOnlyList<FxSemanticId> RegisteredIds { get; }

IRenderPreviewService

interface

Preview rendering for editor tooling. Creates a PreviewContext for a visual profile and releases it again; IsSupported reports whether the active pipeline can serve previews at all.

  • bool IsSupported { get; }
  • PreviewContext CreatePreview(VisualProfileId profileId)
  • void ReleasePreview(PreviewContext context)

MaterialSemanticId

struct

Readonly string-backed id for a material category, with ordinal equality and operators. Requesting "impact.concrete" costs a gameplay system nothing in pipeline knowledge.

  • string Value { get; }
  • bool IsValid { get; }

FxSemanticId

struct

The same shape as MaterialSemanticId, for effect categories such as "fx.muzzle.flash.rifle". Throws on a null constructor argument; whitespace makes it invalid rather than throwing.

  • string Value { get; }
  • bool IsValid { get; }

ResolvedMaterial

class

What a successful material resolution hands back: the semantic id that was asked for, the pipeline-specific AssetPath, and the RenderPipelineTarget that answered.

  • MaterialSemanticId SemanticId { get; }
  • string AssetPath { get; }
  • RenderPipelineTarget Pipeline { get; }

ResolvedFx

class

The effect-side result, carrying FxSemanticId, AssetPath, and the answering pipeline.

  • FxSemanticId SemanticId { get; }
  • string AssetPath { get; }
  • RenderPipelineTarget Pipeline { get; }

RenderPipelineTarget

enum

Unknown, URP, HDRP. Stamped onto every resolution result so a tool can tell which adapter produced a path without asking the registry again.

IVisualEnvironmentService

interface

Scene-visual facade resolved through FoundryServiceRegistry. Consumers apply environment states through it instead of touching RenderSettings or volumes directly.

  • VisualEnvironmentState Current { get; }
  • bool Apply(in VisualEnvironmentState state)
  • event Action<VisualEnvironmentState> Applied
  • void RegisterApplier(IVisualEnvironmentApplier applier)
  • void UnregisterApplier(IVisualEnvironmentApplier applier)

IVisualEnvironmentApplier

interface

A pipeline-specific hook run after the pipeline-agnostic pass. Implemented by the URP and HDRP packages and registered at bootstrap; ApplierId is a diagnostic string such as "urp.exposure".

  • string ApplierId { get; }
  • void Apply(in VisualEnvironmentState state)

VisualEnvironmentState

struct

The environment request itself. Has flags gate ambient, fog, exposure, and skybox independently, so partial application is explicit.

  • bool HasAmbient; EnvironmentColor AmbientColor; float AmbientIntensity;
  • bool HasFog; bool FogEnabled; VisualEnvironmentFogMode FogMode; EnvironmentColor FogColor;
  • float FogDensity; float FogStartDistance; float FogEndDistance;
  • bool HasExposure; float ExposureEv;
  • bool HasSkybox; string SkyboxSemanticId;

RenderSettingsVisualEnvironmentService

class

The default service. Applies ambient, fog, and skybox through RenderSettings, then runs registered appliers in order, catching and logging any that fail. Skybox resolution goes through the active adapter's material resolver.

VisualEnvironmentBootstrap

class

Play-mode self-registration for the default service. Ensure is idempotent and defers to any implementation already registered against the contract.

  • static IVisualEnvironmentService Ensure()

RenderingValidator

class

Static configuration check. Reports RENDER_001 when no adapter is registered, RENDER_002 when adapters exist but none is active, and RENDER_003 when the active adapter reports itself unavailable.

  • static List<RenderingValidationDiagnostic> ValidateAll()

RenderingValidationDiagnostic

class

One validation finding: a severity, a stable Code, a Message, and optional Context. Severity is Info, Warning, or Error.

  • RenderingDiagnosticSeverity Severity { get; }
  • string Code { get; }
  • string Message { get; }
  • string Context { get; }

PreviewContext

class

A live preview handle carrying an Id and the VisualProfileId it previews. Disposable, and IsActive flips to false once released.

  • string Id { get; }
  • VisualProfileId ProfileId { get; }
  • bool IsActive { get; }
  • void Dispose()

VisualProfileDefinition

class

A named visual profile bound to a target pipeline. Plain C# rather than a ScriptableObject, so profile data can be constructed and validated without the editor.

  • VisualProfileId Id { get; }
  • string DisplayName { get; }
  • RenderPipelineTarget TargetPipeline { get; }

Usage

Examples

Registering an adapter and resolving a materialcsharp
using ZOA.Rendering.Core.Models;
using ZOA.Rendering.Core.Registry;
using ZOA.Rendering.Urp.Core.Runtime;

// Bootstrap: one adapter registered, one made active.
var adapter = new UrpRenderPipelineAdapter();
RenderPipelineAdapterRegistry.Register(adapter);
RenderPipelineAdapterRegistry.SetActive(adapter.PipelineId);

// Anywhere afterwards, pipeline-agnostically:
var active = RenderPipelineAdapterRegistry.GetActive();
if (active != null &&
    active.MaterialResolver.TryResolve(new MaterialSemanticId("weapon.metal.primary"), out var resolved))
{
    // resolved.AssetPath is a URP path here, an HDRP path under the other adapter.
    // resolved.Pipeline says which one answered.
}
The only pipeline-specific line is the constructor. Everything downstream goes through the registry and the semantic id.
Applying a visual environmentcsharp
using ZOA.Messaging;
using ZOA.Rendering.Core.Environment;

var service = FoundryServiceRegistry.Get<IVisualEnvironmentService>();

var dusk = new VisualEnvironmentState
{
    HasAmbient = true,
    AmbientColor = new EnvironmentColor(0.18f, 0.20f, 0.28f),
    AmbientIntensity = 0.8f,

    HasFog = true,
    FogEnabled = true,
    FogMode = VisualEnvironmentFogMode.Exponential,
    FogColor = new EnvironmentColor(0.22f, 0.24f, 0.30f),
    FogDensity = 0.012f,

    HasExposure = true,
    ExposureEv = -0.5f,
};

service.Apply(dusk);
HasSkybox is left false, so the scene keeps whatever skybox it already had. Exposure only lands when a pipeline applier is registered.
Contributing a pipeline-specific appliercsharp
using ZOA.Rendering.Core.Environment;
using ZOA.Rendering.Unity.Environment;

public sealed class ProjectColorGradeApplier : IVisualEnvironmentApplier
{
    public string ApplierId => "project.colorgrade";

    public void Apply(in VisualEnvironmentState state)
    {
        if (!state.HasExposure) return;
        // Apply the project's own grading response to state.ExposureEv.
    }
}

// During bootstrap, after the service exists:
var service = VisualEnvironmentBootstrap.Ensure();
service.RegisterApplier(new ProjectColorGradeApplier());
Appliers run after the pipeline-agnostic pass, in registration order. A throwing applier is logged and skipped, not fatal.
Gating a scene on validationcsharp
using ZOA.Rendering.Core.Validation;

foreach (var diagnostic in RenderingValidator.ValidateAll())
{
    if (diagnostic.Severity == RenderingDiagnosticSeverity.Error)
        UnityEngine.Debug.LogError(diagnostic.Code + ": " + diagnostic.Message);
    else
        UnityEngine.Debug.LogWarning(diagnostic.Code + ": " + diagnostic.Message);
}
RENDER_001 means no adapter package is installed or registered, which is the failure that otherwise shows up as every material silently missing.

Read this

Notes and caveats

See also