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