Renderer modules, invalidation, capabilities, and visual intents
Use case
Use these contracts when a project needs retained visual content, action feedback, invalidation behavior, or a renderer capability beyond the built-in modules.
Contract and ownership
Choose the narrowest contract that owns the visual work:
- IGridRendererModule for a complete renderer module;
- IGridCellNodeBuilder or a structure node builder for one kind of retained content;
- a decorator or intent renderer for one focused presentation layer.
Declare the backend capabilities that the module requires. Add an invalidation policy only when a logical change must refresh retained owners beyond the ones already reported.
Modules build renderer-neutral render nodes from render contexts. Backends own physical handles. Stable visual keys, not scene-object identity, connect one synchronization to the next.
Actions remain renderer-neutral by default. Results with affected or invalid cells already produce generic feedback without an action-specific visualization contract.
Implement IGridActionVisualIntentContributor only when an action needs a project-owned channel, color, layer, or render-node kind.
This adds a Visualization assembly dependency to that action. Keep domain actions renderer-neutral when the domain assembly should not take that dependency.
Implementation
The renderer first declares the capability its nodes require:
Excerpt — declare generated-geometry support. Tested using public APIs.
public IReadOnlyCollection<GridRendererCapabilityKey> RequiredCapabilities =>
Capabilities;
public bool AttachesToPrimaryNodes => false;
It then maps each valid intent coordinate to one retained node with a stable key:
Excerpt — iterate the intent coordinates. Tested using public APIs.
public IReadOnlyList<GridRenderNode> BuildIntentNodes(
GridIntentRenderContext context)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
if (surfaceOffset < 0f || float.IsNaN(surfaceOffset)
|| float.IsInfinity(surfaceOffset))
{
throw new InvalidOperationException(
"The tutorial intent surface offset must be finite and non-negative.");
}
List<GridRenderNode> nodes = new();
foreach (GridCoordinate coordinate in context.Intent.Coordinates)
{
GridRenderNode node = BuildNode(context, coordinate);
if (node != null)
nodes.Add(node);
}
return nodes;
}
Excerpt — create one generated-geometry node. Tested using public APIs.
private GridRenderNode BuildNode(GridIntentRenderContext context, GridCoordinate coordinate)
{
if (!context.State.ContainsCell(coordinate))
return null;
if (!context.Layout.TryGetCellGeometry(coordinate, out GridCellGeometry geometry))
{
throw new InvalidOperationException(
$"The layout could not resolve intent geometry for {coordinate}.");
}
Color color = context.Intent.SourceColor.a > 0f
? context.Intent.SourceColor
: context.Intent.GeneratedKind == GridRenderNodeKind.ActionInvalid
? invalidColor
: affectedColor;
return new GridRenderNode(
GridVisualKey.ForCell(
context.Intent.Layer,
context.Intent.ChannelId,
coordinate,
context.State.Definition.Topology.TopologyId),
context.Intent.GeneratedKind,
new GridPose(geometry.LocalCenter + Vector3.up * surfaceOffset, Quaternion.identity),
color,
GridVisualContent.GeneratedShape(),
context.Intent.Layer,
context.Intent.SortOrder,
coordinate,
polygon: geometry.LocalCorners);
}
The module declares generated-geometry capability, turns intent coordinates into retained nodes with stable keys, preserves incoming invalidation, and supplies configuration diagnostics. The complete renderer remains available below for testing or adaptation.
Complete tested example
using System;
using System.Collections.Generic;
using LostLily.GridToolkit.Coordinates;
using LostLily.GridToolkit.Visualization;
using UnityEngine;
namespace GridToolkit.Tutorials
{
[DisallowMultipleComponent]
public sealed class TutorialIntentRenderer :
MonoBehaviour,
IGridIntentRenderer,
IGridVisualInvalidationPolicy,
IGridRendererModuleValidator
{
private static readonly IReadOnlyCollection<GridRendererCapabilityKey> Capabilities =
Array.AsReadOnly(new[] { GridRendererCapabilities.GeneratedGeometry });
[SerializeField] private Color affectedColor =
new(0.25f, 0.75f, 1f, 0.55f);
[SerializeField] private Color invalidColor =
new(1f, 0.25f, 0.25f, 0.65f);
[SerializeField] private float surfaceOffset = 0.06f;
public IReadOnlyCollection<GridRendererCapabilityKey> RequiredCapabilities =>
Capabilities;
public bool AttachesToPrimaryNodes => false;
public IReadOnlyList<GridRenderNode> BuildIntentNodes(
GridIntentRenderContext context)
{
if (context == null)
throw new ArgumentNullException(nameof(context));
if (surfaceOffset < 0f || float.IsNaN(surfaceOffset)
|| float.IsInfinity(surfaceOffset))
{
throw new InvalidOperationException(
"The tutorial intent surface offset must be finite and non-negative.");
}
List<GridRenderNode> nodes = new();
foreach (GridCoordinate coordinate in context.Intent.Coordinates)
{
GridRenderNode node = BuildNode(context, coordinate);
if (node != null)
nodes.Add(node);
}
return nodes;
}
private GridRenderNode BuildNode(GridIntentRenderContext context, GridCoordinate coordinate)
{
if (!context.State.ContainsCell(coordinate))
return null;
if (!context.Layout.TryGetCellGeometry(coordinate, out GridCellGeometry geometry))
{
throw new InvalidOperationException(
$"The layout could not resolve intent geometry for {coordinate}.");
}
Color color = context.Intent.SourceColor.a > 0f
? context.Intent.SourceColor
: context.Intent.GeneratedKind == GridRenderNodeKind.ActionInvalid
? invalidColor
: affectedColor;
return new GridRenderNode(
GridVisualKey.ForCell(
context.Intent.Layer,
context.Intent.ChannelId,
coordinate,
context.State.Definition.Topology.TopologyId),
context.Intent.GeneratedKind,
new GridPose(geometry.LocalCenter + Vector3.up * surfaceOffset, Quaternion.identity),
color,
GridVisualContent.GeneratedShape(),
context.Intent.Layer,
context.Intent.SortOrder,
coordinate,
polygon: geometry.LocalCorners);
}
public GridVisualInvalidation ExpandInvalidation(
GridRendererBinding binding,
GridVisualInvalidation invalidation) =>
invalidation;
public void AddConfigurationDiagnostics(
ICollection<string> diagnostics)
{
if (diagnostics == null)
throw new ArgumentNullException(nameof(diagnostics));
if (surfaceOffset < 0f || float.IsNaN(surfaceOffset)
|| float.IsInfinity(surfaceOffset))
{
diagnostics.Add(
"TutorialIntentRenderer surface offset must be finite and non-negative.");
}
}
}
}
For action-derived feedback, wrap or implement the action contributor contract and return one or
more GridVisualIntent values. Returning no valid intents falls back to generic action feedback.
This visual intent contributor keeps project-specific feedback beside the action while leaving ordinary board actions independent of any renderer.
Excerpt — contribute a custom visual intent for an action result. Tested using public APIs.
public sealed class DocumentationActionVisualIntentContributor :
IGridAction,
IGridActionVisualIntentContributor
{
private readonly IGridAction inner;
public DocumentationActionVisualIntentContributor(IGridAction inner)
{
this.inner = inner ?? throw new ArgumentNullException(nameof(inner));
}
public GridActionResult Build(GridActionPlanBuilder builder) =>
inner.Build(builder);
public IEnumerable<GridVisualIntent> CreateVisualIntents(
GridActionVisualIntentContext context)
{
Color color = context.Result.Success
? new Color(0.2f, 0.8f, 0.35f, 0.85f)
: new Color(0.9f, 0.2f, 0.2f, 0.85f);
yield return new GridVisualIntent(
"game.board-expand",
context.Result.AffectedCells,
color,
GridRenderNodeKind.Overlay,
GridVisualLayer.Overlay,
channelId: "game.board-expand.feedback",
source: this);
}
}
Use
GridTimedVisualIntentController
for short-lived overlays that should fade and remove themselves. TryShow reports invalid boards,
cells, durations, or channel ownership without throwing.
The controller clears every channel it owns when disabled or destroyed. Do not reuse one of its channel IDs from another source.
Use this timed visual path only when the feedback has a real lifetime. Persistent selection or state decoration belongs in retained rendering instead.
An Editor-only setup recipe makes a module/backend discoverable to creation workflows:
Excerpt — identify the setup recipe. Tested using public APIs.
public string Id => "game.renderer";
public string DisplayName => "Game Renderer";
public int SortOrder => 500;
public bool IsAvailable => true;
public float DefaultCellSize => 1f;
public Material CreateGeneratedGeometryMaterial(string materialName) =>
null;
Excerpt — create and validate its host. Tested using public APIs.
public GameObject CreateHost(
string boardName,
Transform preferredParent,
out GameObject undoRoot)
{
undoRoot = new GameObject(boardName);
undoRoot.transform.SetParent(preferredParent, false);
return undoRoot;
}
public bool CanApplyTo(
GameObject host,
out string failureReason)
{
failureReason = host == null ? "A host is required." : string.Empty;
return host != null;
}
Excerpt — apply the renderer-specific configuration. Tested using public APIs.
public MonoBehaviour ApplyTo(
GameObject boardObject,
float cellSize,
GridStructureLibraryAsset structureLibrary,
Material generatedGeometryMaterial) =>
null;
Registration
Assign runtime modules to renderer configuration. Register setup recipes and preview providers in Editor-only registries with stable IDs and explicit cleanup.
Verification
Report unsupported capabilities before synchronization. Keep invalidation expansion deterministic and bounded; a policy that always requests a full rebuild defeats retained rendering.
Test capability rejection, stable node keys, precise invalidation, handle reuse/removal, null or empty node output, action-intent fallback, timed-channel cleanup, setup registration cleanup, and parity between preview and runtime layout.
Custom uGUI content and interaction rules are Preview-tier contracts. Keep their implementation in a uGUI-dependent assembly, declare capability requirements explicitly, and do not make a renderer-neutral module depend on Canvas objects.
Related
- Choose a built-in action
- Display action feedback
- Retained visualization
- Configure a World 2D or World 3D renderer
- IGridCellNodeBuilder
- IGridRendererModule
- IGridStructureNodeBuilder
- IGridIntentRenderer
- IGridActionVisualIntentContributor
- GridTimedVisualIntentController
- IGridVisualInvalidationPolicy
Done when
The module advertises accurate capabilities, refreshes only relevant content, action and timed channels have clear owners, unsupported content is reported, and every owned visual object or channel is released.