From 4da1776627e17ce833eb8fced77c69b094b77a48 Mon Sep 17 00:00:00 2001 From: Justin Marshall Date: Mon, 11 May 2026 09:35:14 -0700 Subject: [PATCH] Editor: Added ability to hide/unhide utility brushes and target lines. Tweaked path tracing so lighting is more realistic. --- neo/engine/opengl/gl_d3d12raylight.cpp | 146 +++++++++++++++------ neo/engine/tools/radiant/CamWnd.cpp | 170 ++++++++++++++++++++++++- 2 files changed, 273 insertions(+), 43 deletions(-) diff --git a/neo/engine/opengl/gl_d3d12raylight.cpp b/neo/engine/opengl/gl_d3d12raylight.cpp index 8a1e0400..02ae86c4 100644 --- a/neo/engine/opengl/gl_d3d12raylight.cpp +++ b/neo/engine/opengl/gl_d3d12raylight.cpp @@ -3374,6 +3374,35 @@ float3 LoadSceneSpecularAlbedo(uint2 pixel, float3 baseAlbedo) return Doom3PseudoSpecularMask(baseAlbedo); } + +float ComputeDiffuseLightingTerm(float3 N, float3 L) +{ + // Keep the original light volume/range attenuation exactly where it is, but + // make the surface response less gamey. The old 0.28-0.32 Half-Lambert wrap + // pushed too much light around silhouettes and into back-facing normal-map + // detail. This smaller squared wrap keeps Doom/idTech readability while + // giving a more Lambert-like, physically plausible rolloff. + float rawNoL = dot(normalize(N), normalize(L)); + + if (gEnableHalfLambert != 0u) + { + const float REALISTIC_WRAP = 0.12; + float wrapped = saturate((rawNoL + REALISTIC_WRAP) / (1.0 + REALISTIC_WRAP)); + return wrapped * wrapped; + } + + return saturate(rawNoL); +} + +float EstimateSpecularRoughness(float3 specularAlbedo) +{ + // No roughness map is available in this G-buffer path, so infer a stable + // perceptual roughness from the specular map strength. Brighter spec maps + // get tighter highlights; dark/missing maps stay broad and subdued. + float peak = SpecularPeak3(saturate(specularAlbedo)); + return clamp(lerp(0.68, 0.34, peak), 0.28, 0.72); +} + float3 ComputeSpecular( float3 N, float3 V, @@ -3387,47 +3416,66 @@ float3 ComputeSpecular( if (gEnableSpecular == 0) return 0.0; + if (atten <= 0.0 || shadow <= 0.0) + return 0.0; + N = normalize(N); V = normalize(V); L = normalize(L); - float NdotL = dot(N, L); - float NdotV = dot(N, V); + float NoL = saturate(dot(N, L)); + float NoV = saturate(dot(N, V)); - if (atten <= 0.0 || shadow <= 0.0) + if (NoL <= 1.0e-4 || NoV <= 1.0e-4) return 0.0; - // Keep Doom/idTech's front-side behavior, but make the gate soft. A hard - // NdotL/NdotV cutoff was making normal-mapped and grazing surfaces randomly - // lose all specular even when the half-angle lobe should still be visible. - float lightFacing = smoothstep(-0.08, 0.18, NdotL); - float viewFacing = smoothstep(-0.04, 0.14, NdotV); - if (lightFacing <= 0.0 || viewFacing <= 0.0) - return 0.0; - - // idTech4/Doom 3 interaction shader uses half-angle style specular, - // not the reflect(-L,N) Phong vector used in the old code here. float3 H = Doom3SafeNormalizeOr(L + V, N); - float NdotH = saturate(dot(N, H)); + float NoH = saturate(dot(N, H)); + float VoH = saturate(dot(V, H)); - float specTerm = Doom3SpecularLookup(NdotH) * lightFacing * viewFacing; - if (specTerm <= 1.0e-5) + if (NoH <= 1.0e-4 || VoH <= 1.0e-4) return 0.0; float3 specMask = saturate(specularAlbedo); + float specPeak = SpecularPeak3(specMask); + if (specPeak <= 0.001) + return 0.0; - // Doom 3's interaction pass is strongly additive. Keep it punchy, - // but clamp enough to avoid fireflies with stochastic light sampling. - const float DOOM3_SPECULAR_SCALE = 4.75; + float roughness = EstimateSpecularRoughness(specMask); + float a = roughness * roughness; + float a2 = max(a * a, 1.0e-4); + + const float PI = 3.14159265; + + // GGX/Trowbridge-Reitz distribution with Smith masking and Schlick Fresnel. + // This is still intentionally stylized for the existing idTech-style assets, + // but it produces more believable view-dependent highlights than the previous + // additive lookup lobe and does not alter light attenuation distance. + float dDenom = NoH * NoH * (a2 - 1.0) + 1.0; + float D = a2 / max(PI * dDenom * dDenom, 1.0e-4); + + float k = ((roughness + 1.0) * (roughness + 1.0)) * 0.125; + float Gv = NoV / max(NoV * (1.0 - k) + k, 1.0e-4); + float Gl = NoL / max(NoL * (1.0 - k) + k, 1.0e-4); + float G = Gv * Gl; + + float3 F0 = saturate(lerp(float3(0.025, 0.025, 0.025), specMask, 0.58)); + float3 F = F0 + (1.0 - F0) * pow(1.0 - VoH, 5.0); + + float specTerm = (D * G) / max(4.0 * NoL * NoV, 1.0e-4); + specTerm = min(specTerm, 5.0); + + const float SPECULAR_ENERGY_SCALE = 2.35; float3 specular = lightColor * lightIntensity * atten * shadow * - specMask * + NoL * + F * specTerm * - DOOM3_SPECULAR_SCALE; + SPECULAR_ENERGY_SCALE; return clamp(specular, 0.0, 8.0); } @@ -3768,17 +3816,16 @@ float3 PathTraceDirectPointLight(uint2 pixel, float3 worldPos, float3 N, float3 float3 L = toLight / dist; - float wrap = 0.28; - float NdotLWrap = saturate((dot(N, L) + wrap) / (1.0 + wrap)); + float diffuseNoL = ComputeDiffuseLightingTerm(N, L); float shadow = 1.0; - if (Lgt.samples != 0u && NdotLWrap > 0.0001) + if (Lgt.samples != 0u && diffuseNoL > 0.0001) shadow = TraceVisibilityBiased(worldPos, N, L, dist); if (Lgt.pointRadiusPad <= 0.5) specAccum += ComputeSpecular(N, V, L, Lgt.color, Lgt.intensity, atten, shadow, specularAlbedo); - diffuseAccum += Lgt.color * (Lgt.intensity * atten * NdotLWrap * shadow); + diffuseAccum += Lgt.color * (Lgt.intensity * atten * diffuseNoL * shadow); } float invSamples = 1.0 / (float)sampleCount; @@ -3798,17 +3845,16 @@ float3 PathTraceDirectSpotLight(float3 worldPos, float3 N, float3 V, float3 base float3 L = toLight / dist; float atten = ComputeSpotLightAttenuation(worldPos, Lgt); - float wrap = 0.28; - float NdotLWrap = saturate((dot(N, L) + wrap) / (1.0 + wrap)); + float diffuseNoL = ComputeDiffuseLightingTerm(N, L); float shadow = 1.0; - if (Lgt.samples != 0u && NdotLWrap > 0.0001 && atten > 0.0) + if (Lgt.samples != 0u && diffuseNoL > 0.0001 && atten > 0.0) shadow = TraceVisibilityBiased(worldPos, N, L, dist); if (Lgt.pointRadiusPad <= 0.5) specularOut = ComputeSpecular(N, V, L, Lgt.color, Lgt.intensity, atten, shadow, specularAlbedo); - return Lgt.color * (Lgt.intensity * atten * NdotLWrap * shadow); + return Lgt.color * (Lgt.intensity * atten * diffuseNoL * shadow); } float3 PathTraceDirectRectLight(uint2 pixel, float3 worldPos, float3 N, float3 V, float3 baseAlbedo, float3 specularAlbedo, Light Lgt, inout uint rng, out float3 specularOut) @@ -3852,7 +3898,7 @@ float3 PathTraceDirectRectLight(uint2 pixel, float3 worldPos, float3 N, float3 V continue; float3 L = sampleVec / sampleDist; - float NdotL = saturate(dot(N, L)); + float NdotL = ComputeDiffuseLightingTerm(N, L); if (NdotL <= 0.0) continue; @@ -4019,8 +4065,7 @@ float3 EstimateFastBounceLight(float3 hitPos, float3 hitN, Light Lgt) if (atten <= 0.0) return 0.0; - float wrap = 0.32; - float nDotL = saturate((dot(hitN, L) + wrap) / (1.0 + wrap)); + float nDotL = ComputeDiffuseLightingTerm(hitN, L); return clamp(Lgt.color * (Lgt.intensity * atten * nDotL), 0.0, 8.0); } else if (Lgt.type == GL_RAYTRACING_LIGHT_TYPE_SPOT) @@ -4035,8 +4080,7 @@ float3 EstimateFastBounceLight(float3 hitPos, float3 hitN, Light Lgt) if (atten <= 0.0) return 0.0; - float wrap = 0.32; - float nDotL = saturate((dot(hitN, L) + wrap) / (1.0 + wrap)); + float nDotL = ComputeDiffuseLightingTerm(hitN, L); return clamp(Lgt.color * (Lgt.intensity * atten * nDotL), 0.0, 8.0); } else if (Lgt.type == GL_RAYTRACING_LIGHT_TYPE_RECT) @@ -4053,7 +4097,7 @@ float3 EstimateFastBounceLight(float3 hitPos, float3 hitN, Light Lgt) return 0.0; float3 L = toCenter / centerDist; - float nDotL = saturate(dot(hitN, L)); + float nDotL = ComputeDiffuseLightingTerm(hitN, L); if (nDotL <= 0.0) return 0.0; @@ -4507,8 +4551,8 @@ float3 PathTraceDeterministicLighting( float3 skyColor = skyColorRGB * (0.35 + 0.65 * upness); float3 lightingAccum = gAmbientColor.rgb * (gAmbientColor.a * 0.04); - lightingAccum += skyColor * (0.70 * skyVis); - lightingAccum += ambientSkyVis * (skyColorRGB * 0.15); + lightingAccum += skyColor * (0.42 * skyVis); + lightingAccum += ambientSkyVis * (skyColorRGB * 0.12); // Glow-map emissive is direct/bloom-only for now. It is added after lighting // in RayGen and is intentionally not injected into lightingAccum. @@ -4805,6 +4849,24 @@ float3 EstimateRayTracedSpecularReflection( } )" R"( +float3 ApplyRealisticOutputCurve(float3 color) +{ + // Final photographic shoulder only: it does not change light radius or + // attenuation, but it prevents intense local lights/specular/bloom from + // clipping into a flat white patch. Values below 1.0 are left untouched. + color = max(color, 0.0); + + float peak = max(max(color.r, color.g), color.b); + if (peak > 1.0) + { + float over = peak - 1.0; + float shoulderPeak = 1.0 + over / (1.0 + over * 0.38); + color *= shoulderPeak / max(peak, 1.0e-5); + } + + return max(color, 0.0); +} + [shader("raygeneration")] void RayGen() { @@ -4822,7 +4884,7 @@ void RayGen() if (depthSample <= 0.0 || depthSample >= 1.0) { - gOutputTex[pixel] = float4(albedoSample.rgb + emissiveSurface + emissiveBloom, albedoSample.a); + gOutputTex[pixel] = float4(ApplyRealisticOutputCurve(albedoSample.rgb + emissiveSurface + emissiveBloom), albedoSample.a); return; } @@ -4840,7 +4902,7 @@ void RayGen() if (isUnlit) { - gOutputTex[pixel] = float4(baseAlbedo + emissiveSurface + emissiveBloom, albedoSample.a); + gOutputTex[pixel] = float4(ApplyRealisticOutputCurve(baseAlbedo + emissiveSurface + emissiveBloom), albedoSample.a); return; } @@ -4851,8 +4913,8 @@ void RayGen() // then reuse them for every stochastic GI sample. float cavity = ComputeCavity(pixel, worldPos, N); float ao = ComputeAmbientOcclusion(worldPos, N, pixel); - float skyVis = 0; //ComputeSkyVisibility(worldPos, N, pixel); // jmarshall - fix me later - float ambientSkyVis = TraceStraightUpToSky(worldPos, N); + float skyVis = 0; //ComputeSkyVisibility(worldPos, N, pixel); + float ambientSkyVis = 0; //TraceStraightUpToSky(worldPos, N); float microShadow = lerp(0.75, 1.0, cavity); float3 reactiveFinalGather = 0.0; @@ -4947,7 +5009,7 @@ void RayGen() // the eventual swap-chain/backbuffer is LDR. finalColor += emissiveSurface + emissiveBloom; - gOutputTex[pixel] = float4(max(finalColor * ao, 0.0), albedoSample.a); + gOutputTex[pixel] = float4(ApplyRealisticOutputCurve(finalColor), albedoSample.a); } )"; diff --git a/neo/engine/tools/radiant/CamWnd.cpp b/neo/engine/tools/radiant/CamWnd.cpp index 630bf04f..87d01e05 100644 --- a/neo/engine/tools/radiant/CamWnd.cpp +++ b/neo/engine/tools/radiant/CamWnd.cpp @@ -1012,12 +1012,16 @@ static const char* CAMWND_PROP_FILTER_MASK = "QER_CamWnd_FilterMask"; #define CAMWND_MENU_CMD_SHOW_ENTITIES 62104 #define CAMWND_MENU_CMD_SHOW_LIGHTS 62105 #define CAMWND_MENU_CMD_RESET_FILTERS 62106 +#define CAMWND_MENU_CMD_SHOW_TARGET_LINES 62107 +#define CAMWND_MENU_CMD_SHOW_TRIGGER_BRUSHES 62108 #define CAMWND_FILTER_HIDE_WORLD 0x00000001 #define CAMWND_FILTER_HIDE_PATCHES 0x00000002 #define CAMWND_FILTER_HIDE_MODELS 0x00000004 #define CAMWND_FILTER_HIDE_ENTITIES 0x00000008 #define CAMWND_FILTER_HIDE_LIGHTS 0x00000010 +#define CAMWND_FILTER_HIDE_TARGET_LINES 0x00000020 +#define CAMWND_FILTER_HIDE_TRIGGER_BRUSHES 0x00000040 static LRESULT CALLBACK CamWnd_MenuBarProc(HWND hWnd, UINT uMsg, WPARAM wParam, LPARAM lParam); @@ -1186,6 +1190,12 @@ static void CamWnd_HandleMenuCommand(CCamWnd* cam, int command) { case CAMWND_MENU_CMD_SHOW_LIGHTS: CamWnd_ToggleFilterBit(cam, CAMWND_FILTER_HIDE_LIGHTS); break; + case CAMWND_MENU_CMD_SHOW_TARGET_LINES: + CamWnd_ToggleFilterBit(cam, CAMWND_FILTER_HIDE_TARGET_LINES); + break; + case CAMWND_MENU_CMD_SHOW_TRIGGER_BRUSHES: + CamWnd_ToggleFilterBit(cam, CAMWND_FILTER_HIDE_TRIGGER_BRUSHES); + break; case CAMWND_MENU_CMD_RESET_FILTERS: CamWnd_ResetFilters(cam); break; @@ -1241,6 +1251,9 @@ static void CamWnd_ShowFiltersMenu(HWND hWnd, CCamWnd* cam) { CamWnd_AppendVisibleFilterItem(menu, mask, CAMWND_FILTER_HIDE_ENTITIES, CAMWND_MENU_CMD_SHOW_ENTITIES, "Show entities"); CamWnd_AppendVisibleFilterItem(menu, mask, CAMWND_FILTER_HIDE_LIGHTS, CAMWND_MENU_CMD_SHOW_LIGHTS, "Show lights"); AppendMenu(menu, MF_SEPARATOR, 0, NULL); + CamWnd_AppendVisibleFilterItem(menu, mask, CAMWND_FILTER_HIDE_TARGET_LINES, CAMWND_MENU_CMD_SHOW_TARGET_LINES, "Show actor/link lines"); + CamWnd_AppendVisibleFilterItem(menu, mask, CAMWND_FILTER_HIDE_TRIGGER_BRUSHES, CAMWND_MENU_CMD_SHOW_TRIGGER_BRUSHES, "Show trigger/utility brushes"); + AppendMenu(menu, MF_SEPARATOR, 0, NULL); AppendMenu(menu, MF_STRING, CAMWND_MENU_CMD_RESET_FILTERS, "Reset filters"); command = TrackPopupMenu(menu, TPM_RETURNCMD | TPM_LEFTALIGN | TPM_TOPALIGN, pt.x, pt.y, 0, hWnd, NULL); @@ -1370,6 +1383,154 @@ static void CamWnd_UpdateRuntimeState(CCamWnd* cam, bool renderMode, bool rebuil } } +static bool CamWnd_StringStartsWithNoCase(const char* text, const char* prefix) { + if (text == NULL || prefix == NULL || prefix[0] == '\0') { + return false; + } + return idStr::Icmpn(text, prefix, (int)strlen(prefix)) == 0; +} + +static bool CamWnd_StringContainsNoCase(const char* text, const char* needle) { + if (text == NULL || needle == NULL || needle[0] == '\0') { + return false; + } + + const int needleLen = (int)strlen(needle); + for (const char* scan = text; *scan; scan++) { + if (idStr::Icmpn(scan, needle, needleLen) == 0) { + return true; + } + } + return false; +} + +static bool CamWnd_MaterialPathHasSegment(const char* materialName, const char* segment) { + if (materialName == NULL || segment == NULL || segment[0] == '\0') { + return false; + } + + const int segmentLen = (int)strlen(segment); + for (const char* scan = materialName; *scan; scan++) { + const bool segmentStart = (scan == materialName || scan[-1] == '/' || scan[-1] == '\\'); + if (!segmentStart) { + continue; + } + + if (idStr::Icmpn(scan, segment, segmentLen) != 0) { + continue; + } + + const char end = scan[segmentLen]; + if (end == '\0' || end == '/' || end == '\\') { + return true; + } + } + return false; +} + +static bool CamWnd_IsUtilityMaterial(const idMaterial* material) { + if (material == NULL || material->GetName() == NULL || material->GetName()[0] == '\0') { + return false; + } + + const char* materialName = material->GetName(); + + // Nodraw is the important case here and may appear outside textures/common + // in some projects, so match it anywhere in the material name. + if (CamWnd_StringContainsNoCase(materialName, "nodraw")) { + return true; + } + + // The broader names below are only treated as utility when they live in a + // common/editor tool-material folder. This avoids hiding ordinary art + // materials that happen to contain words like "clip" or "portal". + if (!CamWnd_MaterialPathHasSegment(materialName, "common") && + !CamWnd_MaterialPathHasSegment(materialName, "editor")) { + return false; + } + + static const char* const utilityTokens[] = { + "caulk", + "trigger", + "clip", + "playerclip", + "monsterclip", + "moveclip", + "missileclip", + "aas", + "ladder", + "origin", + "portal", + "visportal", + "hint", + "skip", + "collision" + }; + + for (int i = 0; i < (int)(sizeof(utilityTokens) / sizeof(utilityTokens[0])); i++) { + if (CamWnd_StringContainsNoCase(materialName, utilityTokens[i])) { + return true; + } + } + + return false; +} + +static bool CamWnd_BrushUsesOnlyUtilityMaterials(brush_t* brush) { + if (brush == NULL) { + return false; + } + + if (brush->pPatch) { + return CamWnd_IsUtilityMaterial(brush->pPatch->d_texture); + } + + bool foundMaterial = false; + for (face_t* face = brush->brush_faces; face; face = face->next) { + if (face->face_winding == NULL) { + continue; + } + + if (face->d_texture == NULL) { + return false; + } + + foundMaterial = true; + if (!CamWnd_IsUtilityMaterial(face->d_texture)) { + return false; + } + } + + return foundMaterial; +} + +static bool CamWnd_IsTriggerLikeBrushEntity(brush_t* brush) { + if (brush == NULL || brush->owner == NULL) { + return false; + } + + const char* className = ValueForKey(brush->owner, "classname"); + if (className == NULL || className[0] == '\0') { + return false; + } + + // These brush entities are editor/gameplay volumes rather than visible map + // geometry, so let the camera filter hide them independently from normal + // func_* brush models such as doors, movers, and func_static. + return CamWnd_StringStartsWithNoCase(className, "trigger") || + CamWnd_StringStartsWithNoCase(className, "func_trigger") || + CamWnd_StringStartsWithNoCase(className, "func_clip") || + CamWnd_StringStartsWithNoCase(className, "func_playerclip") || + CamWnd_StringStartsWithNoCase(className, "func_monsterclip") || + CamWnd_StringStartsWithNoCase(className, "clip") || + CamWnd_StringStartsWithNoCase(className, "playerclip") || + CamWnd_StringStartsWithNoCase(className, "monsterclip"); +} + +static bool CamWnd_TargetLinesVisible(CCamWnd* cam) { + return (CamWnd_GetFilterMask(cam) & CAMWND_FILTER_HIDE_TARGET_LINES) == 0; +} + static bool CamWnd_MenuFilterBrush(CCamWnd* cam, brush_t* brush) { if (!brush) { return false; @@ -1394,8 +1555,13 @@ static bool CamWnd_MenuFilterBrush(CCamWnd* cam, brush_t* brush) { const bool isModel = (brush->modelHandle > 0) || (hasOwner && brush->owner->eclass->entityModel) || brush->entityModel; const bool isBrushModel = hasOwner && IsBModel(brush); const bool isFixedEntity = hasOwner && brush->owner->eclass->fixedsize; + const bool isTriggerLikeBrushEntity = CamWnd_IsTriggerLikeBrushEntity(brush); + const bool isUtilityMaterialBrush = CamWnd_BrushUsesOnlyUtilityMaterials(brush); const bool isEntity = isLight || isModel || isBrushModel || isFixedEntity; + if ((mask & CAMWND_FILTER_HIDE_TRIGGER_BRUSHES) && (isTriggerLikeBrushEntity || isUtilityMaterialBrush)) { + return true; + } if ((mask & CAMWND_FILTER_HIDE_LIGHTS) && isLight) { return true; } @@ -2641,7 +2807,9 @@ void CCamWnd::Cam_Draw() { // draw pointfile glEnable(GL_DEPTH_TEST); - CamWnd_DrawVisiblePathLines(); + if (CamWnd_TargetLinesVisible(this)) { + CamWnd_DrawVisiblePathLines(); + } if (g_qeglobals.d_pointfile_display_list) { Pointfile_Draw();