diff --git a/Assets/Resources/Prefabs/Trough.prefab b/Assets/Resources/Prefabs/Trough.prefab
index b4fece6..8939103 100644
--- a/Assets/Resources/Prefabs/Trough.prefab
+++ b/Assets/Resources/Prefabs/Trough.prefab
@@ -56,6 +56,13 @@ MonoBehaviour:
PlayfieldExitKickerItem:
Ball: {fileID: 0}
BallCount: 6
+ BallPrefabs:
+ - {fileID: 0}
+ - {fileID: 0}
+ - {fileID: 0}
+ - {fileID: 0}
+ - {fileID: 0}
+ - {fileID: 0}
SwitchCount: 6
JamSwitch: 0
RollTime: 300
diff --git a/Assets/Resources/Prefabs/DefaultBall.prefab b/Assets/Resources/Prefabs/UrpBall.prefab
similarity index 97%
rename from Assets/Resources/Prefabs/DefaultBall.prefab
rename to Assets/Resources/Prefabs/UrpBall.prefab
index 0cffa06..01a01ec 100644
--- a/Assets/Resources/Prefabs/DefaultBall.prefab
+++ b/Assets/Resources/Prefabs/UrpBall.prefab
@@ -11,7 +11,7 @@ GameObject:
- component: {fileID: 8928362396421481961}
- component: {fileID: 1782985097271273431}
m_Layer: 0
- m_Name: DefaultBall
+ m_Name: UrpBall
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
@@ -92,7 +92,7 @@ MeshFilter:
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 8440806518127559228}
- m_Mesh: {fileID: 10207, guid: 0000000000000000e000000000000000, type: 0}
+ m_Mesh: {fileID: 4983844940297228651, guid: d78bc7cfea69c684c8c631e82dd9e206, type: 3}
--- !u!23 &7632225475359270604
MeshRenderer:
m_ObjectHideFlags: 0
diff --git a/Assets/Resources/Prefabs/DefaultBall.prefab.meta b/Assets/Resources/Prefabs/UrpBall.prefab.meta
similarity index 100%
rename from Assets/Resources/Prefabs/DefaultBall.prefab.meta
rename to Assets/Resources/Prefabs/UrpBall.prefab.meta
diff --git a/Editor.meta b/Editor.meta
new file mode 100644
index 0000000..b2f21eb
--- /dev/null
+++ b/Editor.meta
@@ -0,0 +1,8 @@
+fileFormatVersion: 2
+guid: fbe02743bfd44074380f9269a2fd5dee
+folderAsset: yes
+DefaultImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/MaterialAdapter.cs b/Editor/MaterialAdapter.cs
similarity index 95%
rename from Runtime/MaterialAdapter.cs
rename to Editor/MaterialAdapter.cs
index 7467bf7..b4f201d 100644
--- a/Runtime/MaterialAdapter.cs
+++ b/Editor/MaterialAdapter.cs
@@ -19,6 +19,7 @@
using NLog;
using UnityEngine;
+using VisualPinball.Unity.Editor;
using Logger = NLog.Logger;
namespace VisualPinball.Unity.Urp
diff --git a/Runtime/MaterialAdapter.cs.meta b/Editor/MaterialAdapter.cs.meta
similarity index 100%
rename from Runtime/MaterialAdapter.cs.meta
rename to Editor/MaterialAdapter.cs.meta
diff --git a/Runtime/PrefabProvider.cs b/Editor/PrefabProvider.cs
similarity index 96%
rename from Runtime/PrefabProvider.cs
rename to Editor/PrefabProvider.cs
index 16e2acb..4086e56 100644
--- a/Runtime/PrefabProvider.cs
+++ b/Editor/PrefabProvider.cs
@@ -17,6 +17,7 @@
using System;
using UnityEngine;
using VisualPinball.Engine.VPT;
+using VisualPinball.Unity.Editor;
namespace VisualPinball.Unity.Urp
{
diff --git a/Runtime/PrefabProvider.cs.meta b/Editor/PrefabProvider.cs.meta
similarity index 100%
rename from Runtime/PrefabProvider.cs.meta
rename to Editor/PrefabProvider.cs.meta
diff --git a/Editor/UniversalRenderPipelineConverter.cs b/Editor/UniversalRenderPipelineConverter.cs
new file mode 100644
index 0000000..207865a
--- /dev/null
+++ b/Editor/UniversalRenderPipelineConverter.cs
@@ -0,0 +1,35 @@
+// Visual Pinball Engine
+// Copyright (C) 2020 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable UnusedType.Global
+// ReSharper disable CheckNamespace
+
+using VisualPinball.Unity;
+using VisualPinball.Unity.Editor;
+
+namespace VisualPinball.Unity.Urp
+{
+ public class UniversalRenderPipelineConverter : IRenderPipelineConverter
+ {
+ public const string PrefabPath = "Packages/org.visualpinball.engine.unity.urp/Prefabs";
+
+ public string Name => "Universal Render Pipeline";
+
+ public RenderPipelineType Type => RenderPipelineType.Urp;
+ public IMaterialAdapter MaterialAdapter { get; } = new MaterialAdapter();
+ public IPrefabProvider PrefabProvider { get; } = new PrefabProvider();
+ }
+}
diff --git a/Editor/UniversalRenderPipelineConverter.cs.meta b/Editor/UniversalRenderPipelineConverter.cs.meta
new file mode 100644
index 0000000..bc39b44
--- /dev/null
+++ b/Editor/UniversalRenderPipelineConverter.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: e0d87b6978d422940b5e4b9969424925
\ No newline at end of file
diff --git a/Editor/VisualPinball.Engine.Unity.Urp.Editor.asmdef b/Editor/VisualPinball.Engine.Unity.Urp.Editor.asmdef
new file mode 100644
index 0000000..e9c8ab9
--- /dev/null
+++ b/Editor/VisualPinball.Engine.Unity.Urp.Editor.asmdef
@@ -0,0 +1,23 @@
+{
+ "name": "VisualPinball.Engine.Unity.Urp.Editor",
+ "rootNamespace": "VisualPinball.Unity.Urp",
+ "references": [
+ "VisualPinball.Engine",
+ "VisualPinball.Engine.Editor",
+ "VisualPinball.Unity",
+ "VisualPinball.Unity.Patcher",
+ "VisualPinball.Unity.Editor",
+ "VisualPinball.Engine.Unity.Urp",
+ "Unity.RenderPipelines.Core.Runtime",
+ "Unity.RenderPipelines.Universal.Runtime"
+ ],
+ "includePlatforms": [ "Editor" ],
+ "excludePlatforms": [],
+ "allowUnsafeCode": true,
+ "overrideReferences": false,
+ "precompiledReferences": [],
+ "autoReferenced": true,
+ "defineConstraints": [],
+ "versionDefines": [],
+ "noEngineReferences": false
+}
diff --git a/Editor/VisualPinball.Engine.Unity.Urp.Editor.asmdef.meta b/Editor/VisualPinball.Engine.Unity.Urp.Editor.asmdef.meta
new file mode 100644
index 0000000..b781e44
--- /dev/null
+++ b/Editor/VisualPinball.Engine.Unity.Urp.Editor.asmdef.meta
@@ -0,0 +1,7 @@
+fileFormatVersion: 2
+guid: 903ad68751f6b2548b549db5a8af9246
+AssemblyDefinitionImporter:
+ externalObjects: {}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/Runtime/BallConverter.cs b/Runtime/BallConverter.cs
index 3df509a..cd2f05e 100644
--- a/Runtime/BallConverter.cs
+++ b/Runtime/BallConverter.cs
@@ -25,7 +25,7 @@ public class BallConverter : IBallConverter
{
public GameObject CreateDefaultBall()
{
- return UnityEngine.Resources.Load("Prefabs/DefaultBall");
+ return UnityEngine.Resources.Load("Prefabs/UrpBall");
}
}
}
diff --git a/Runtime/MaterialConverter.cs b/Runtime/MaterialConverter.cs
index e44b85e..224a2ca 100644
--- a/Runtime/MaterialConverter.cs
+++ b/Runtime/MaterialConverter.cs
@@ -155,28 +155,45 @@ public void SetMaterialType(Material material, MaterialType materialType)
// todo
}
+ // URP/Lit has a single HDR _EmissionColor instead of HDRP's LDR-color + intensity split.
+ // The equivalent semantics used here: intensity = max component of the material's emission
+ // color, chroma = emission color normalized by that maximum. GetEmissiveIntensity therefore
+ // returns the authored full-on intensity (the baseline LightComponent remembers), and
+ // SetEmissiveIntensity rebuilds chroma × intensity into the property block.
+
+ private static readonly int EmissionColor = Shader.PropertyToID("_EmissionColor");
+
public void SetEmissiveColor(MaterialPropertyBlock propBlock, Color color)
{
- // urp has no emissive color
+ propBlock.SetColor(EmissionColor, color);
}
public Color? GetEmissiveColor(Material material)
{
- // urp has no emissive color
-
- return null;
+ if (!material || !material.HasProperty(EmissionColor)) {
+ return null;
+ }
+ return material.GetColor(EmissionColor);
}
public void SetEmissiveIntensity(Material material, MaterialPropertyBlock propBlock, float intensity)
{
- // urp has no emissive intensity
+ if (!material || !material.HasProperty(EmissionColor)) {
+ return;
+ }
+ var baseline = material.GetColor(EmissionColor);
+ var max = baseline.maxColorComponent;
+ var chroma = max > 0f ? baseline / max : Color.white;
+ chroma.a = 1f;
+ propBlock.SetColor(EmissionColor, chroma * intensity);
}
public float GetEmissiveIntensity(Material material)
{
- // urp has no emissive intensity
- //
- return 0;
+ if (!material || !material.HasProperty(EmissionColor) || !material.IsKeywordEnabled("_EMISSION")) {
+ return 0;
+ }
+ return material.GetColor(EmissionColor).maxColorComponent;
}
}
}
diff --git a/Runtime/UniversalRenderPipeline.cs b/Runtime/UniversalRenderPipeline.cs
index 71347d3..e6f3492 100644
--- a/Runtime/UniversalRenderPipeline.cs
+++ b/Runtime/UniversalRenderPipeline.cs
@@ -27,9 +27,7 @@ public class UniversalRenderPipeline : IRenderPipeline
public RenderPipelineType Type { get; } = RenderPipelineType.Urp;
public IMaterialConverter MaterialConverter { get; } = new MaterialConverter();
- public IMaterialAdapter MaterialAdapter { get; } = new MaterialAdapter();
public ILightConverter LightConverter { get; } = new LightConverter();
public IBallConverter BallConverter { get; } = new BallConverter();
- public IPrefabProvider PrefabProvider { get; } = new PrefabProvider();
}
}
diff --git a/Runtime/UrpMaterialResolver.cs b/Runtime/UrpMaterialResolver.cs
new file mode 100644
index 0000000..c68b635
--- /dev/null
+++ b/Runtime/UrpMaterialResolver.cs
@@ -0,0 +1,409 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable CheckNamespace
+
+using UnityEngine;
+using UnityEngine.Rendering;
+using VisualPinball.Unity;
+
+namespace VisualPinball.Unity.Urp
+{
+ // Turns portable VpeMaterialProfiles (from a .vpe package's materials.json) into live
+ // URP/Lit materials in the Player, replacing the placeholder materials glTFast produces for
+ // the stripped table.glb. This is the URP counterpart of HdrpMaterialResolver.
+ //
+ // First pass: handles the portable vpe.lit core (base color/map, metallic/smoothness,
+ // HDRP-style mask map split into URP metallic-gloss + occlusion, normal map, emissive,
+ // surface/blend state, double-sided). Shadergraph types (metal/rubber/dmd) and decals fall
+ // back to a plain lit build from the profile's Lit data, or are left to glTFast when no Lit
+ // data exists.
+ public sealed class UrpMaterialResolver : IVpeMaterialResolver
+ {
+ private static Shader _litShader;
+ private static Shader LitShader => _litShader ? _litShader : (_litShader = Shader.Find("Universal Render Pipeline/Lit"));
+
+ private static readonly int BaseColorId = Shader.PropertyToID("_BaseColor");
+ private static readonly int BaseMapId = Shader.PropertyToID("_BaseMap");
+ private static readonly int MetallicId = Shader.PropertyToID("_Metallic");
+ private static readonly int SmoothnessId = Shader.PropertyToID("_Smoothness");
+ private static readonly int MetallicGlossMapId = Shader.PropertyToID("_MetallicGlossMap");
+ private static readonly int OcclusionMapId = Shader.PropertyToID("_OcclusionMap");
+ private static readonly int OcclusionStrengthId = Shader.PropertyToID("_OcclusionStrength");
+ private static readonly int BumpMapId = Shader.PropertyToID("_BumpMap");
+ private static readonly int BumpScaleId = Shader.PropertyToID("_BumpScale");
+ private static readonly int EmissionColorId = Shader.PropertyToID("_EmissionColor");
+ private static readonly int EmissionMapId = Shader.PropertyToID("_EmissionMap");
+ private static readonly int SurfaceId = Shader.PropertyToID("_Surface");
+ private static readonly int BlendId = Shader.PropertyToID("_Blend");
+ private static readonly int AlphaClipId = Shader.PropertyToID("_AlphaClip");
+ private static readonly int CutoffId = Shader.PropertyToID("_Cutoff");
+ private static readonly int SrcBlendId = Shader.PropertyToID("_SrcBlend");
+ private static readonly int DstBlendId = Shader.PropertyToID("_DstBlend");
+ private static readonly int ZWriteId = Shader.PropertyToID("_ZWrite");
+ private static readonly int CullId = Shader.PropertyToID("_Cull");
+
+ public bool Supports(string materialType)
+ {
+ return materialType switch {
+ VpeMaterialTypes.Lit => true,
+ VpeMaterialTypes.Insert => true,
+ VpeMaterialTypes.Decal => true,
+ VpeMaterialTypes.Metal => true,
+ VpeMaterialTypes.Rubber => true,
+ VpeMaterialTypes.FabricSilk => true,
+ VpeMaterialTypes.Unlit => true,
+ _ => false,
+ };
+ }
+
+ public Material CreateMaterial(VpeMaterialProfile profile, IVpeTextureProvider textures, Material importedMaterial)
+ {
+ if (profile == null) {
+ return null;
+ }
+
+ if (profile.Type == VpeMaterialTypes.Insert) {
+ return BuildInsert(profile, textures, importedMaterial);
+ }
+ if (profile.Type == VpeMaterialTypes.Decal) {
+ return BuildDecal(profile, textures, importedMaterial);
+ }
+
+ var lit = profile.Lit;
+ if (lit == null && profile.Fabric != null) {
+ lit = profile.Fabric.Lit;
+ }
+ if (lit == null) {
+ // No portable lit data (e.g. shadergraph-only or decal); keep glTFast's material.
+ return null;
+ }
+
+ return BuildLit(profile.Name, lit, textures, importedMaterial);
+ }
+
+ #region Decals
+
+ // vpe.decal is authored as a decal *mesh* in VPE tables: dedicated geometry floating a
+ // hair above the surface it decorates, with the albedo alpha controlling where the decal
+ // applies. URP's decal system only handles projectors, so the URP realization renders the
+ // same mesh with an alpha-blended lit overlay built from the decal inputs. In HDRP the
+ // glb fallback for these meshes is a texture-free HDRP/Decal clone (invisible under URP),
+ // which is why unsupported decals show nothing rather than something white.
+ private static Material BuildDecal(VpeMaterialProfile profile, IVpeTextureProvider textures, Material imported)
+ {
+ var decal = profile.Decal;
+ if (decal == null || !LitShader) {
+ return null;
+ }
+
+ var material = new Material(LitShader) { name = profile.Name, enableInstancing = true };
+
+ var baseColor = (Color)decal.BaseColor.Color;
+ // HDRP's Decal Blend scales the whole decal's opacity; fold it into the base alpha.
+ baseColor.a *= Mathf.Clamp01(decal.DecalBlend);
+ material.SetColor(BaseColorId, baseColor);
+ var baseTex = ResolveTexture(decal.BaseColor.Texture, textures, imported, "_BaseMap");
+ if (baseTex) {
+ material.SetTexture(BaseMapId, baseTex);
+ ApplyScaleOffset(material, BaseMapId, decal.BaseColor.Texture);
+ }
+
+ material.SetFloat(MetallicId, decal.Metallic);
+ material.SetFloat(SmoothnessId, decal.Smoothness);
+
+ // Same HDRP mask-map reuse as BuildLit: R=metallic/A=smoothness feed _MetallicGlossMap,
+ // G=AO feeds _OcclusionMap.
+ if (decal.AffectMask && decal.MaskMap != null && !string.IsNullOrWhiteSpace(decal.MaskMap.TextureId)
+ && decal.MaskPacking == VpeMaskPackings.HdrpMaskMap) {
+ var mask = textures?.Get(decal.MaskMap.TextureId);
+ if (mask) {
+ material.SetTexture(MetallicGlossMapId, mask);
+ material.EnableKeyword("_METALLICSPECGLOSSMAP");
+ material.SetTexture(OcclusionMapId, mask);
+ material.EnableKeyword("_OCCLUSIONMAP");
+ material.SetFloat(OcclusionStrengthId, decal.AmbientOcclusion);
+ }
+ }
+
+ var normalTex = decal.AffectNormal
+ ? ResolveTexture(decal.NormalMap?.TextureId, textures, imported, "_BumpMap")
+ : null;
+ if (normalTex) {
+ material.SetTexture(BumpMapId, normalTex);
+ material.SetFloat(BumpScaleId, decal.NormalMap.Strength);
+ material.EnableKeyword("_NORMALMAP");
+ ApplyScaleOffset(material, BumpMapId, decal.NormalMap.Offset, decal.NormalMap.Scale);
+ }
+
+ // Transparent alpha overlay: the base-map alpha decides where the decal applies.
+ material.SetFloat(SurfaceId, 1f);
+ material.SetFloat(AlphaClipId, 0f);
+ material.DisableKeyword("_ALPHATEST_ON");
+ material.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
+ material.SetFloat(BlendId, 0f);
+ material.SetFloat(SrcBlendId, (float)BlendMode.SrcAlpha);
+ material.SetFloat(DstBlendId, (float)BlendMode.OneMinusSrcAlpha);
+ material.SetFloat(ZWriteId, 0f);
+ material.SetOverrideTag("RenderType", "Transparent");
+ material.renderQueue = (int)RenderQueue.Transparent;
+ material.globalIlluminationFlags = MaterialGlobalIlluminationFlags.EmissiveIsBlack;
+
+ return material;
+ }
+
+ #endregion
+
+ #region Inserts
+
+ // URP has no translucency, diffusion profiles or refraction, so an insert cannot be lit
+ // "through" its plastic the way HDRP does it. The approximation: the transmittance color
+ // (the physical color of the insert plastic) becomes the visible body color, and the lamp
+ // glow becomes emission that LightComponent drives with the lamp value at runtime — the
+ // same seam that drives faux bulbs, via MaterialConverter.Get/SetEmissiveIntensity.
+ //
+ // Both parts stay in the transparent queue: LightComponent hides *opaque* emissive
+ // renderers when the lamp is off (faux bulbs), while transparent ones only get their
+ // emission zeroed and stay visible — which is exactly what an insert needs.
+
+ // Opacity floors. Authored base alpha is ~0.1 (clear plastic relying on transmission for
+ // its color); URP shows the color through the base map instead.
+ private const float ReflectorOpacity = 0.9f;
+ private const float LensOpacity = 0.4f;
+
+ // Emission at full lamp intensity, in URP emission units (post-ReferenceEv100 scale).
+ // Standard alpha blending scales emission by the body alpha. Kept low enough that the
+ // tonemapper preserves the insert's chroma (over-driving it washes lit inserts to white).
+ private const float ReflectorGlow = 4f;
+ private const float LensGlow = 6f;
+
+ private static Material BuildInsert(VpeMaterialProfile profile, IVpeTextureProvider textures, Material imported)
+ {
+ var insert = profile.Insert;
+ var lit = insert?.Lit;
+ if (lit == null) {
+ return null;
+ }
+
+ var material = BuildLit(profile.Name, lit, textures, imported);
+ if (!material) {
+ return null;
+ }
+
+ var isReflector = insert.Part == VpeInsertParts.Reflector;
+
+ // Color identity: a reflector's most saturated color is its transmittance tint (the
+ // color it gives transmitted lamp light); a lens carries its color in the authored
+ // base color — its transmittance is typically a generic hat value shared across colors.
+ var tint = isReflector ? (Color)lit.TransmittanceColor : (Color)lit.BaseColor.Color;
+ tint.a = 1f;
+ var baseColor = (Color)lit.BaseColor.Color;
+ var bodyColor = Color.Lerp(baseColor, tint, 0.85f);
+ bodyColor.a = Mathf.Max(baseColor.a, isReflector ? ReflectorOpacity : LensOpacity);
+ material.SetColor(BaseColorId, bodyColor);
+
+ // The authored blend mode may be premultiply; URP/Lit expects the premultiply keyword
+ // for that to look right, so force plain alpha blending — with the boosted opacity
+ // above it reads much closer to the HDRP lens anyway.
+ material.SetFloat(BlendId, 0f);
+ material.SetFloat(SrcBlendId, (float)BlendMode.SrcAlpha);
+ material.SetFloat(DstBlendId, (float)BlendMode.OneMinusSrcAlpha);
+
+ // Emission baseline: chroma from the tint, magnitude = full-lamp glow. LightComponent
+ // reads this baseline at Awake, zeroes it, and scales it with the lamp value.
+ var chroma = tint.maxColorComponent > 0f ? tint / tint.maxColorComponent : Color.white;
+ chroma.a = 1f;
+ material.SetColor(EmissionColorId, chroma * (isReflector ? ReflectorGlow : LensGlow));
+ material.EnableKeyword("_EMISSION");
+ material.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
+
+ // Keep the lens above the reflector within the transparent queue.
+ material.renderQueue = (int)RenderQueue.Transparent + lit.SortPriority;
+
+ return material;
+ }
+
+ #endregion
+
+ private static Material BuildLit(string name, VpeLitProfile lit, IVpeTextureProvider textures, Material imported)
+ {
+ if (lit == null || !LitShader) {
+ return null;
+ }
+
+ var material = new Material(LitShader) { name = name, enableInstancing = true };
+
+ // --- base color + map ---
+ material.SetColor(BaseColorId, (Color)lit.BaseColor.Color);
+ var baseTex = ResolveTexture(lit.BaseColor.Texture, textures, imported, "_BaseMap");
+ if (baseTex) {
+ material.SetTexture(BaseMapId, baseTex);
+ ApplyScaleOffset(material, BaseMapId, lit.BaseColor.Texture);
+ }
+
+ // --- metallic / smoothness ---
+ material.SetFloat(MetallicId, lit.Metallic);
+ material.SetFloat(SmoothnessId, lit.Smoothness);
+
+ // --- mask map ---
+ // HDRP MaskMap packs R=metallic, G=AO, B=detail, A=smoothness. URP/Lit reads metallic
+ // from R and smoothness from A of _MetallicGlossMap, and AO from G of _OcclusionMap, so
+ // the very same texture feeds both slots.
+ if (lit.MaskMap != null && !string.IsNullOrWhiteSpace(lit.MaskMap.TextureId)
+ && lit.MaskPacking == VpeMaskPackings.HdrpMaskMap) {
+ var mask = textures?.Get(lit.MaskMap.TextureId);
+ if (mask) {
+ material.SetTexture(MetallicGlossMapId, mask);
+ material.EnableKeyword("_METALLICSPECGLOSSMAP");
+ material.SetTexture(OcclusionMapId, mask);
+ material.EnableKeyword("_OCCLUSIONMAP");
+ material.SetFloat(OcclusionStrengthId, lit.OcclusionStrength);
+ // URP computes smoothness = mask.a * _Smoothness; HDRP uses lerp(remapMin,remapMax,mask.a)
+ // and ignores the scalar when a mask is present. Use the remap max so mask.a drives
+ // smoothness fully instead of being scaled down by the (mask-irrelevant) scalar.
+ material.SetFloat(SmoothnessId, lit.SmoothnessRemap.y);
+ }
+ }
+
+ // --- normal map ---
+ // Source normals are plain RGB (alpha=1); URP's UnpackNormalmapRGorAG reads X from R*A,
+ // so a plain RGB normal samples correctly without the AG repack HDRP needs.
+ var normalTex = ResolveTexture(lit.NormalMap?.TextureId, textures, imported, "_BumpMap");
+ if (normalTex) {
+ material.SetTexture(BumpMapId, normalTex);
+ material.SetFloat(BumpScaleId, lit.NormalMap.Strength);
+ material.EnableKeyword("_NORMALMAP");
+ ApplyScaleOffset(material, BumpMapId, lit.NormalMap.Offset, lit.NormalMap.Scale);
+ }
+
+ // --- emissive ---
+ // Authored emissive colors are HDRP-physical (nits); map them into URP's unitless
+ // emission through the shared reference-exposure scale.
+ var emissive = (Color)lit.Emissive.Color * UrpPhysicalUnits.EmissiveScale;
+ emissive.a = 1f;
+ if (emissive.maxColorComponent > 0.0001f) {
+ material.SetColor(EmissionColorId, emissive);
+ var emissiveTex = ResolveTexture(lit.Emissive.Texture, textures, imported, "_EmissionMap");
+ if (emissiveTex) {
+ material.SetTexture(EmissionMapId, emissiveTex);
+ }
+ material.EnableKeyword("_EMISSION");
+ material.globalIlluminationFlags = MaterialGlobalIlluminationFlags.RealtimeEmissive;
+ } else {
+ material.globalIlluminationFlags = MaterialGlobalIlluminationFlags.EmissiveIsBlack;
+ }
+
+ ApplySurface(material, lit);
+
+ material.SetFloat(CullId, lit.DoubleSided ? (float)CullMode.Off : (float)CullMode.Back);
+ material.doubleSidedGI = lit.DoubleSidedGi;
+
+ return material;
+ }
+
+ private static void ApplySurface(Material material, VpeLitProfile lit)
+ {
+ switch (lit.SurfaceType) {
+ case VpeSurfaceTypes.Transparent:
+ material.SetFloat(SurfaceId, 1f);
+ material.SetFloat(AlphaClipId, 0f);
+ material.DisableKeyword("_ALPHATEST_ON");
+ material.EnableKeyword("_SURFACE_TYPE_TRANSPARENT");
+ material.SetFloat(ZWriteId, 0f);
+ material.SetOverrideTag("RenderType", "Transparent");
+ material.renderQueue = (int)RenderQueue.Transparent;
+ ApplyBlend(material, lit.BlendMode);
+ break;
+
+ case VpeSurfaceTypes.AlphaTest:
+ material.SetFloat(SurfaceId, 0f);
+ material.SetFloat(AlphaClipId, 1f);
+ material.SetFloat(CutoffId, lit.AlphaCutoff);
+ material.EnableKeyword("_ALPHATEST_ON");
+ material.DisableKeyword("_SURFACE_TYPE_TRANSPARENT");
+ material.SetFloat(SrcBlendId, (float)BlendMode.One);
+ material.SetFloat(DstBlendId, (float)BlendMode.Zero);
+ material.SetFloat(ZWriteId, 1f);
+ material.SetOverrideTag("RenderType", "TransparentCutout");
+ material.renderQueue = (int)RenderQueue.AlphaTest;
+ break;
+
+ default: // Opaque
+ material.SetFloat(SurfaceId, 0f);
+ material.SetFloat(AlphaClipId, 0f);
+ material.DisableKeyword("_ALPHATEST_ON");
+ material.DisableKeyword("_SURFACE_TYPE_TRANSPARENT");
+ material.SetFloat(SrcBlendId, (float)BlendMode.One);
+ material.SetFloat(DstBlendId, (float)BlendMode.Zero);
+ material.SetFloat(ZWriteId, 1f);
+ material.SetOverrideTag("RenderType", "Opaque");
+ material.renderQueue = (int)RenderQueue.Geometry;
+ break;
+ }
+ }
+
+ private static void ApplyBlend(Material material, string blendMode)
+ {
+ switch (blendMode) {
+ case VpeBlendModes.Additive:
+ material.SetFloat(BlendId, 2f);
+ material.SetFloat(SrcBlendId, (float)BlendMode.SrcAlpha);
+ material.SetFloat(DstBlendId, (float)BlendMode.One);
+ break;
+ case VpeBlendModes.Premultiply:
+ material.SetFloat(BlendId, 1f);
+ material.SetFloat(SrcBlendId, (float)BlendMode.One);
+ material.SetFloat(DstBlendId, (float)BlendMode.OneMinusSrcAlpha);
+ break;
+ default: // Alpha
+ material.SetFloat(BlendId, 0f);
+ material.SetFloat(SrcBlendId, (float)BlendMode.SrcAlpha);
+ material.SetFloat(DstBlendId, (float)BlendMode.OneMinusSrcAlpha);
+ break;
+ }
+ }
+
+ private static Texture ResolveTexture(VpeTextureRef textureRef, IVpeTextureProvider textures, Material imported, string importedProperty)
+ {
+ return ResolveTexture(textureRef?.TextureId, textures, imported, importedProperty);
+ }
+
+ private static Texture ResolveTexture(string textureId, IVpeTextureProvider textures, Material imported, string importedProperty)
+ {
+ Texture texture = null;
+ if (!string.IsNullOrWhiteSpace(textureId)) {
+ texture = textures?.Get(textureId);
+ }
+ if (!texture && imported && !string.IsNullOrEmpty(importedProperty) && imported.HasProperty(importedProperty)) {
+ texture = imported.GetTexture(importedProperty);
+ }
+ return texture;
+ }
+
+ private static void ApplyScaleOffset(Material material, int propertyId, VpeTextureRef textureRef)
+ {
+ if (textureRef != null) {
+ ApplyScaleOffset(material, propertyId, textureRef.Offset, textureRef.Scale);
+ }
+ }
+
+ private static void ApplyScaleOffset(Material material, int propertyId, Vector2 offset, Vector2 scale)
+ {
+ material.SetTextureOffset(propertyId, offset);
+ material.SetTextureScale(propertyId, scale);
+ }
+ }
+}
diff --git a/Runtime/UrpMaterialResolver.cs.meta b/Runtime/UrpMaterialResolver.cs.meta
new file mode 100644
index 0000000..2b89568
--- /dev/null
+++ b/Runtime/UrpMaterialResolver.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 0b457a29ac3c5134091a2776fee381ba
\ No newline at end of file
diff --git a/Runtime/UrpMaterialResolverBootstrap.cs b/Runtime/UrpMaterialResolverBootstrap.cs
new file mode 100644
index 0000000..8b00f06
--- /dev/null
+++ b/Runtime/UrpMaterialResolverBootstrap.cs
@@ -0,0 +1,36 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable CheckNamespace
+
+using UnityEngine;
+using VisualPinball.Unity;
+
+namespace VisualPinball.Unity.Urp
+{
+ // Registers the URP material resolver with VpeMaterialResolver before any scene loads, the
+ // same pattern as the HDRP package's VpeMaterialResolverBootstrap. Also registers the light
+ // unit adjuster that converts authored HDRP-physical light intensities into URP's units.
+ public static class UrpMaterialResolverBootstrap
+ {
+ [RuntimeInitializeOnLoadMethod(RuntimeInitializeLoadType.BeforeSceneLoad)]
+ private static void Register()
+ {
+ VpeMaterialResolver.Register(new UrpMaterialResolver());
+ VpeLightUnitAdjuster.Register(UrpPhysicalUnits.AdjustRestoredLight);
+ }
+ }
+}
diff --git a/Runtime/UrpMaterialResolverBootstrap.cs.meta b/Runtime/UrpMaterialResolverBootstrap.cs.meta
new file mode 100644
index 0000000..b7638cb
--- /dev/null
+++ b/Runtime/UrpMaterialResolverBootstrap.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 5648d6e0030b6374eba79caf60aa18f2
\ No newline at end of file
diff --git a/Runtime/UrpPhysicalUnits.cs b/Runtime/UrpPhysicalUnits.cs
new file mode 100644
index 0000000..575b348
--- /dev/null
+++ b/Runtime/UrpPhysicalUnits.cs
@@ -0,0 +1,69 @@
+// Visual Pinball Engine
+// Copyright (C) 2026 freezy and VPE Team
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with this program. If not, see .
+
+// ReSharper disable CheckNamespace
+
+using UnityEngine;
+
+namespace VisualPinball.Unity.Urp
+{
+ // .vpe tables are authored under HDRP in physical units: emissives in nits, punctual lights
+ // in candela (Light.intensity holds the candela value), directionals in lux. HDRP tone-maps
+ // those through exposure (signal = luminance / (1.2 * 2^EV100)); URP has neither physical
+ // units nor an exposure system, so this is the single place where physical values are mapped
+ // into URP's unitless lighting.
+ public static class UrpPhysicalUnits
+ {
+ // Reference exposure baked into the conversion. Calibrated 2026-07-02 by A/B region-stats
+ // against the HDRP player under the neutral wooden_studio_17_1k HDRI (same camera pose,
+ // only table lights on): EV 4.5 brings GI-lamp pools and faux-bulb cores in line with
+ // HDRP's histogram exposure. Tune this one value if overall URP lamp/emissive brightness
+ // drifts from the HDRP player.
+ public const float ReferenceEv100 = 5f;
+
+ // Extra stops applied to emissive surfaces on top of ReferenceEv100. A/B stats showed
+ // faux-bulb cores/halos still ~1 EV hotter than HDRP when lights already matched —
+ // URP's bloom picks emissive HDR values up more aggressively than HDRP's.
+ public const float EmissiveExtraEv = 1f;
+
+ // nits (emissive luminance) → URP emission value.
+ public static readonly float EmissiveScale = 1f / (1.2f * Mathf.Pow(2f, ReferenceEv100 + EmissiveExtraEv));
+
+ // candela / lux → URP light intensity. URP's Lambert term carries no 1/π (the legacy
+ // Unity convention bakes it into the light), so physical light intensities additionally
+ // divide by π. Derived from ReferenceEv100 alone — EmissiveExtraEv does not apply here.
+ public static readonly float LightScale = 1f / (1.2f * Mathf.Pow(2f, ReferenceEv100) * Mathf.PI);
+
+ // HDRP spreads each lamp's light through SSGI (screen-space bounce); URP has no GI, so
+ // table lamps only produce their direct pools. Widening the authored ranges makes the
+ // overlapping pools stand in for the first bounce — and unlike a static ambient lift it
+ // follows the lamp state (GI string off → playfield goes dark, as in HDRP).
+ public const float LightRangeMultiplier = 3.5f;
+
+ // Registered with VpeLightUnitAdjuster at bootstrap; runs after each restored light
+ // profile has been applied with its authored (HDRP-physical) values.
+ public static void AdjustRestoredLight(Light light)
+ {
+ if (!light) {
+ return;
+ }
+ light.intensity *= LightScale;
+ if (light.type is LightType.Point or LightType.Spot) {
+ light.range *= LightRangeMultiplier;
+ }
+ }
+ }
+}
diff --git a/Runtime/UrpPhysicalUnits.cs.meta b/Runtime/UrpPhysicalUnits.cs.meta
new file mode 100644
index 0000000..14d65af
--- /dev/null
+++ b/Runtime/UrpPhysicalUnits.cs.meta
@@ -0,0 +1,2 @@
+fileFormatVersion: 2
+guid: 14342a43133dd4b4e954db52ae4bf883
\ No newline at end of file