Local Fog Volumes render nothing on macOS (Metal SM6, Apple M3 Max) in -game and Movie Render Queue

Summary

On macOS, Local Fog Volumes have no visible effect at all in a -game run or a Movie Render Queue render. The volume is registered with the scene: the debug overlay text reports its start distance. But nothing is drawn, not even the tile debug colours of r.LocalFogVolume.TileDebug 2, and that holds with volumetric fog disabled, as the documentation requires for the debug view. Exponential Height Fog, volumetric fog and Volume-domain materials render correctly in the same scene.
Unreal Engine 5.8.4 (Launcher build, CL 58902052, ++UE5+Release-5.8)

  • macOS 15.6.1 (24G90)
  • MacBook Pro Mac15,11, Apple M3 Max (30-core GPU), 36 GB unified memory, Metal 3
  • RHI: Metal, shader platform METAL_SM6; driver internal 329.2
  • Project settings default for Local Fog Volumes (r.SupportLocalFogVolumes 1). Deferred renderer, Lumen GI and reflections, virtual shadow maps.

What Type of Bug are you experiencing?

Level Design & Art Tools

Steps to Reproduce

Empty level containing: a floor, three spot lights, an Exponential Height Fog (density 0.05, volumetric fog on), a post-process volume with manual exposure, and a camera at (-1000, 0, 260) looking down +X.
2. Add a Local Fog Volume at (300, -500, 200), scale 0.6, Radial Fog Extinction 2 to 5, Height Fog Extinction 0, Fog Emissive (4, 0.6, 0.3) or up to (40, 4, 1.5), albedo near white.
3. Render it with Movie Render Queue (-game -LevelSequence=... -MoviePipelineConfig=...), or run the level with -game and take HighResShot.
4. Repeat with r.VolumetricFog 0 and r.LocalFogVolume.TileDebug 2.
voltest.py (attached) builds the whole test level, sequence and render configs headless (UnrealEditor-Cmd … -run=pythonscript -script=voltest.py`). The same level also holds a Volume-domain material mesh, which renders correctly.

Expected Result

A visible emissive or extinguishing sphere of fog at the volume’s location.

  • With TileDebug 2 and volumetric fog off, coloured debug tiles over the volume’s screen area.

Observed Result

No visible change at the volume’s location in any configuration: the image is identical to a scene without the volume.

  • With TileDebug 2, only the canvas text appears: “LOCAL FOG VOLUME DEBUG VIEW / Global Start Distance = 1000.0 / Instances Start Distance = 1092.8”. No tile colours are drawn anywhere, with volumetric fog on or off, in -game or MRQ.
  • These settings changed nothing (MRQ console variables, also set with -ExecCmds):
    • r.LocalFogVolume.RenderIntoVolumetricFog 0
    • r.LocalFogVolume.RenderDuringHeightFogPass 1
    • r.LocalFogVolume.UseHZB 0 with r.LocalFogVolume.TileCullingUseAsync 0
    • r.LocalFogVolume.GlobalStartDistance 10
    • r.LocalFogVolume.MaxDensityIntoVolumetricFog 1.0
  • These volume changes didn’t help: an arbitrarily rotated volume (to rule out the packed rotation reading back as denormals) and very high emissive.
  • Adding a Sky Atmosphere and a visible Volumetric Cloud didn’t help either. A forum user on an M3 Max reported that as a workaround (thread linked below).

Notes from reading the 5.8 source

  • ShouldRenderLocalFogVolume returns true: the debug overlay text is drawn by RenderLocalFogVolumeVisualization, which requires it.
  • In debug mode 2, LocalFogVolumeTiledPS clips only when the contribution equals identity. No tiles appear, so either the tiled culling (LocalFogVolumeTiledCulling.usf) produces zero tiles or no indirect draws, or every pixel evaluates to identity in GetLFVInstanceContribution.
  • The per-instance data goes through a Buffer<float4> SRV (PF_A32B32G32R32F) with integer bit patterns (half pairs, R11G11B10, RGBA8) and is read back with asuint. If Metal flushes denormal floats on typed buffer loads, that data could be corrupted. A rotated volume didn’t change the result, though, so this is unconfirmed.
  • Possibly related reports, none with an Epic answer or bug number:

Affects Versions

5.8

Platform(s)

Mac

Upload an image

Additional Notes

A small test level for fog volumes in Movie Render Queue, apart from any film’s level: three ways of putting fog in

one place, side by side under three spots, in a thin height fog. Left: Local Fog Volume. Middle: a mesh with a Volume

domain material (voxelized into the volumetric fog). Right: a translucent box raymarching its own fog.

ue_run.sh scripts/voltest.py, then voltest.sh base|debug|novox|clamp|hfog|nohzb|near|novf|debugnovf

import unreal

LEVEL = ‘/Game/voltest’
OUT = ‘/Users/bogdan/av-studio/renders/voltest’
les = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
eas = unreal.get_editor_subsystem(unreal.EditorActorSubsystem)
eal = unreal.EditorAssetLibrary
mel = unreal.MaterialEditingLibrary
at = unreal.AssetToolsHelpers.get_asset_tools()
CUBE = unreal.load_asset(‘/Engine/BasicShapes/Cube’)
X, Z = 300.0, 200.0
YS = {‘lfv’: -500.0, ‘vol’: 0.0, ‘ray’: 500.0}
SIZE = 3.0 # the volumes’ scale: 300 cm across

def fresh(path):
if eal.does_asset_exist(path):
eal.delete_asset(path)

def material(name):
path = ‘/Game/mat/’ + name
fresh(path)
return at.create_asset(name, ‘/Game/mat’, unreal.Material, unreal.MaterialFactoryNew())

def const3(m, v, x, y):
e = mel.create_material_expression(m, unreal.MaterialExpressionConstant3Vector, x, y)
e.set_editor_property(‘constant’, unreal.LinearColor(*v, 1.0))
return e

def const1(m, v, x, y):
e = mel.create_material_expression(m, unreal.MaterialExpressionConstant, x, y)
e.set_editor_property(‘r’, v)
return e

PATCH = ‘’’
float3 d = (WP - Obj) / R; // -1..1 across the volume’s mesh
float edge = saturate(1.0 - length(d));
float3 p = WP * 0.004 + float3(Time * 0.05, -Time * 0.03, Time * 0.02);
float n = 0.0, amp = 0.5;
for (int i = 0; i < 4; i++) {
float3 f = frac(p), q = floor(p);
f = f * f * (3.0 - 2.0 * f);
float h000 = frac(sin(dot(q, float3(127.1, 311.7, 74.7))) * 43758.5);
float h100 = frac(sin(dot(q + float3(1, 0, 0), float3(127.1, 311.7, 74.7))) * 43758.5);
float h010 = frac(sin(dot(q + float3(0, 1, 0), float3(127.1, 311.7, 74.7))) * 43758.5);
float h110 = frac(sin(dot(q + float3(1, 1, 0), float3(127.1, 311.7, 74.7))) * 43758.5);
float h001 = frac(sin(dot(q + float3(0, 0, 1), float3(127.1, 311.7, 74.7))) * 43758.5);
float h101 = frac(sin(dot(q + float3(1, 0, 1), float3(127.1, 311.7, 74.7))) * 43758.5);
float h011 = frac(sin(dot(q + float3(0, 1, 1), float3(127.1, 311.7, 74.7))) * 43758.5);
float h111 = frac(sin(dot(q + float3(1, 1, 1), float3(127.1, 311.7, 74.7))) * 43758.5);
n += amp * lerp(lerp(lerp(h000, h100, f.x), lerp(h010, h110, f.x), f.y), lerp(lerp(h001, h101, f.x), lerp(h011, h111, f.x), f.y), f.z);
p = p * 2.03 + 1.7;
amp *= 0.5;
}
return Ext * edge * edge * saturate((n - 0.3) * 2.5);
‘’’

def volume_domain():
“”“Fog in one place: a mesh whose Volume material is voxelized into the volumetric fog, so the scene’s lights
scatter in it (with their shadows). Density: soft-edged inside the mesh, drifting noise; no glow of its own.”“”
m = material(‘M_voltest_volume’)
m.set_editor_property(‘material_domain’, unreal.MaterialDomain.MD_VOLUME)
m.set_editor_property(‘blend_mode’, unreal.BlendMode.BLEND_ADDITIVE)
mel.connect_material_property(const3(m, (1.0, 0.91, 0.81), -300, 0), ‘’, unreal.MaterialProperty.MP_BASE_COLOR)
c = mel.create_material_expression(m, unreal.MaterialExpressionCustom, -500, 300)
c.set_editor_property(‘code’, PATCH)
c.set_editor_property(‘output_type’, unreal.CustomMaterialOutputType.CMOT_FLOAT1)
inputs =
for n in (‘WP’, ‘Obj’, ‘R’, ‘Time’, ‘Ext’):
ci = unreal.CustomInput()
ci.set_editor_property(‘input_name’, n)
inputs.append(ci)
c.set_editor_property(‘inputs’, inputs)
src = {‘WP’: mel.create_material_expression(m, unreal.MaterialExpressionWorldPosition, -900, 200),
‘Obj’: mel.create_material_expression(m, unreal.MaterialExpressionObjectPositionWS, -900, 300),
‘R’: const1(m, 50.0 * SIZE, -900, 400),
‘Time’: mel.create_material_expression(m, unreal.MaterialExpressionTime, -900, 500),
‘Ext’: const1(m, 6.0, -900, 600)}
for n, e in src.items():
mel.connect_material_expressions(e, ‘’, c, n)
# (a Volume material takes its extinction on the Subsurface Color pin, not Opacity)
mel.connect_material_property(c, ‘’, unreal.MaterialProperty.MP_SUBSURFACE_COLOR)
mel.recompile_material(m)
eal.save_loaded_asset(m)
return m

RAY = ‘’’
float3 rd = normalize(WP - Cam);
float3 ro = Cam - C;
float3 inv = 1.0 / rd;
float3 a = (-H - ro) * inv, b = (H - ro) * inv;
float3 lo = min(a, b), hi = max(a, b);
float t0 = max(max(max(lo.x, lo.y), lo.z), 0.0);
float t1 = min(min(hi.x, hi.y), hi.z);
float toSurface = Depth / max(dot(rd, ResolvedView.ViewForward), 0.001);
t1 = min(t1, toSurface);
if (t1 <= t0) return float4(0, 4, 0, 0.5); // (debug: green where the ray misses)
const int N = 24;
float dt = (t1 - t0) / N;
float T = 1.0;
float3 glow = 0;
for (int i = 0; i < N; i++) {
float3 p = ro + rd * (t0 + dt * (i + 0.5));
float fall = saturate(1.0 - length(p) / H);
float w = sin(p.x * 0.021 + 1.0) * sin(p.y * 0.017 + 2.0) * sin(p.z * 0.023)
+ 0.5 * sin(p.x * 0.053 + p.z * 0.02) * sin(p.y * 0.047 + 4.0) * sin(p.z * 0.061 + 1.0);
float d = Dens * fall * fall * saturate(0.6 + w);
float aa = 1.0 - exp(-d * dt);
glow += T * aa * Col * saturate(0.35 + 0.65 * (p.z + H) / (2.0 * H)); // lit from above
T *= 1.0 - aa;
}
return float4(glow, 1.0 - T);
‘’’

def raymarch():
m = material(‘M_voltest_ray’)
m.set_editor_property(‘blend_mode’, unreal.BlendMode.BLEND_TRANSLUCENT)
m.set_editor_property(‘shading_model’, unreal.MaterialShadingModel.MSM_UNLIT)
m.set_editor_property(‘disable_depth_test’, True)
c = mel.create_material_expression(m, unreal.MaterialExpressionCustom, -500, 0)
c.set_editor_property(‘code’, RAY)
c.set_editor_property(‘output_type’, unreal.CustomMaterialOutputType.CMOT_FLOAT4)
names = [‘WP’, ‘Cam’, ‘Depth’, ‘C’, ‘H’, ‘Dens’, ‘Col’]
inputs =
for n in names:
ci = unreal.CustomInput()
ci.set_editor_property(‘input_name’, n)
inputs.append(ci)
c.set_editor_property(‘inputs’, inputs)
src = {‘WP’: mel.create_material_expression(m, unreal.MaterialExpressionWorldPosition, -900, 0),
‘Cam’: mel.create_material_expression(m, unreal.MaterialExpressionCameraPositionWS, -900, 100),
‘Depth’: mel.create_material_expression(m, unreal.MaterialExpressionSceneDepth, -900, 200),
‘C’: const3(m, (X, YS[‘ray’], Z), -900, 300),
‘H’: const1(m, 50.0 * SIZE, -900, 400),
‘Dens’: const1(m, 0.02, -900, 500),
‘Col’: const3(m, (4.0, 3.2, 2.4), -900, 600)}
for n, e in src.items():
mel.connect_material_expressions(e, ‘’, c, n)
rgb = mel.create_material_expression(m, unreal.MaterialExpressionComponentMask, -200, 0)
for k, v in zip(‘rgba’, (True, True, True, False)):
rgb.set_editor_property(k, v)
al = mel.create_material_expression(m, unreal.MaterialExpressionComponentMask, -200, 150)
for k, v in zip(‘rgba’, (False, False, False, True)):
al.set_editor_property(k, v)
mel.connect_material_expressions(c, ‘’, rgb, ‘’)
mel.connect_material_expressions(c, ‘’, al, ‘’)
mel.connect_material_property(rgb, ‘’, unreal.MaterialProperty.MP_EMISSIVE_COLOR)
mel.connect_material_property(al, ‘’, unreal.MaterialProperty.MP_OPACITY)
mel.recompile_material(m)
eal.save_loaded_asset(m)
return m

def box(loc, scale, mat=None):
a = eas.spawn_actor_from_class(unreal.StaticMeshActor, unreal.Vector(*loc))
a.static_mesh_component.set_static_mesh(CUBE)
if mat not in (None, M_GREY):
a.static_mesh_component.set_cast_shadow(False)
a.set_actor_scale3d(unreal.Vector(*scale))
if mat:
a.static_mesh_component.set_material(0, mat)
return a

M_VOL, M_RAY = volume_domain(), raymarch()
M_GREY = material(‘M_voltest_grey’)
mel.connect_material_property(const3(M_GREY, (0.15, 0.15, 0.15), -300, 0), ‘’, unreal.MaterialProperty.MP_BASE_COLOR)
mel.recompile_material(M_GREY)
eal.save_loaded_asset(M_GREY)

if eal.does_asset_exist(LEVEL): # (a loaded level can’t be replaced: clear it instead)
les.load_level(LEVEL)
for old in eas.get_all_level_actors():
eas.destroy_actor(old)
else:
les.new_level(LEVEL)
box((0, 0, -5), (40, 40, 0.1), M_GREY)
box((1000, 0, 400), (0.1, 40, 10), M_GREY)
w = eas.spawn_actor_from_class(unreal.RectLight, unreal.Vector(400, 0, 300), unreal.Rotator(0, 0, 0))
w.rect_light_component.set_editor_property(‘intensity’, 40.0)
w.rect_light_component.set_editor_property(‘source_width’, 3000.0)
for key, y in YS.items():
s = eas.spawn_actor_from_class(unreal.SpotLight, unreal.Vector(X, y, 700), unreal.Rotator(0, -90, 0))
lc = s.spot_light_component
lc.set_editor_property(‘intensity_units’, unreal.LightUnits.CANDELAS)
lc.set_editor_property(‘intensity’, 8000.0)
lc.set_editor_property(‘attenuation_radius’, 1500.0)
lc.set_editor_property(‘outer_cone_angle’, 22.0)
lc.set_editor_property(‘inner_cone_angle’, 12.0)
lc.set_editor_property(‘volumetric_scattering_intensity’, 4.0)
lc.set_editor_property(‘mobility’, unreal.ComponentMobility.MOVABLE)

fog = eas.spawn_actor_from_class(unreal.ExponentialHeightFog, unreal.Vector(0, 0, 0))
fc = fog.component
fc.set_editor_property(‘fog_density’, 0.05)
fc.set_editor_property(‘fog_height_falloff’, 0.001)
fc.set_editor_property(‘fog_inscattering_luminance’, unreal.LinearColor(0, 0, 0, 1))
fc.set_editor_property(‘enable_volumetric_fog’, True)
fc.set_editor_property(‘volumetric_fog_distance’, 6000.0)

pp = eas.spawn_actor_from_class(unreal.PostProcessVolume, unreal.Vector(0, 0, 0))
pp.set_editor_property(‘unbound’, True)
st = pp.settings
st.set_editor_property(‘override_auto_exposure_method’, True)
st.set_editor_property(‘auto_exposure_method’, unreal.AutoExposureMethod.AEM_MANUAL)
st.set_editor_property(‘override_auto_exposure_apply_physical_camera_exposure’, True)
st.set_editor_property(‘auto_exposure_apply_physical_camera_exposure’, False)
st.set_editor_property(‘override_auto_exposure_bias’, True)
st.set_editor_property(‘auto_exposure_bias’, -6.0)
pp.set_editor_property(‘settings’, st)

1 Local Fog Volume

(rotated a little on every axis: an unrotated volume’s packed rotation reads back as denormal floats, which Apple

GPUs flush to zero, and the volume draws nothing)

lf = eas.spawn_actor_from_class(unreal.LocalFogVolume, unreal.Vector(X, YS[‘lfv’], Z), unreal.Rotator(roll=11.0, pitch=7.0, yaw=37.0))
lf.set_actor_scale3d(unreal.Vector(0.6, 0.6, 0.6)) # (its base size is 1000 cm across)
lfc = lf.get_component_by_class(unreal.LocalFogVolumeComponent)
lfc.set_editor_property(‘radial_fog_extinction’, 2.0)
lfc.set_editor_property(‘fog_albedo’, unreal.LinearColor(1.0, 0.9, 0.8, 1.0))
lfc.set_editor_property(‘height_fog_extinction’, 0.0)
lfc.set_editor_property(‘fog_emissive’, unreal.LinearColor(4.0, 0.6, 0.3, 1.0))
print(‘LFV mobility’, lfc.get_editor_property(‘mobility’), ‘visible’, lfc.is_visible())

2 a mesh with a Volume domain material

box((X, YS[‘vol’], Z), (SIZE, SIZE, SIZE), M_VOL)

3 a box raymarching its own fog

box((X, YS[‘ray’], Z), (SIZE, SIZE, SIZE), M_RAY)

cam = eas.spawn_actor_from_class(unreal.CineCameraActor, unreal.Vector(-1000, 0, 260), unreal.Rotator(0, -3, 0))
cam.get_cine_camera_component().set_editor_property(‘current_focal_length’, 18.0)

the same view for a plain game run (no Movie Render Queue): the default pawn spawns here

eas.spawn_actor_from_class(unreal.PlayerStart, unreal.Vector(-1000, 0, 200), unreal.Rotator(0, 0, 0))
les.save_current_level()

path = ‘/Game/seq_voltest’
fresh(path)
seq = at.create_asset(‘seq_voltest’, ‘/Game’, unreal.LevelSequence, unreal.LevelSequenceFactoryNew())
seq.set_display_rate(unreal.FrameRate(24, 1))
seq.set_playback_start(0)
seq.set_playback_end(3)
b = seq.add_possessable(cam)
cuts = seq.add_track(unreal.MovieSceneCameraCutTrack)
cs = cuts.add_section()
cs.set_range(0, 3)
bid = unreal.MovieSceneObjectBindingID()
bid.set_editor_property(‘guid’, b.get_id())
cs.set_camera_binding_id(bid)
eal.save_loaded_asset(seq)

def config(name, cvars):
p = ‘/Game/mrq/voltest_’ + name
fresh(p)
cfg = at.create_asset(‘voltest_’ + name, ‘/Game/mrq’, unreal.MoviePipelinePrimaryConfig,
unreal.MoviePipelinePrimaryConfigFactory())
out = cfg.find_or_add_setting_by_class(unreal.MoviePipelineOutputSetting)
out.output_directory = unreal.DirectoryPath(OUT)
out.file_name_format = name + ‘_{frame_number}’
out.output_resolution = unreal.IntPoint(1280, 720)
out.override_existing_output = True
cfg.find_or_add_setting_by_class(unreal.MoviePipelineDeferredPassBase)
cfg.find_or_add_setting_by_class(unreal.MoviePipelineImageSequenceOutput_JPG)
aa = cfg.find_or_add_setting_by_class(unreal.MoviePipelineAntiAliasingSetting)
aa.override_anti_aliasing = True
aa.anti_aliasing_method = unreal.AntiAliasingMethod.AAM_TSR
aa.engine_warm_up_count = 24
aa.render_warm_up_count = 24
aa.use_camera_cut_for_warm_up = False
cv = cfg.find_or_add_setting_by_class(unreal.MoviePipelineConsoleVariableSetting)
for k, v in cvars:
cv.add_or_update_console_variable(k, v)
eal.save_loaded_asset(cfg)

config(‘base’, ())
config(‘debug’, ((‘r.LocalFogVolume.TileDebug’, 2),))
config(‘novox’, ((‘r.LocalFogVolume.RenderIntoVolumetricFog’, 0),))
config(‘clamp’, ((‘r.LocalFogVolume.MaxDensityIntoVolumetricFog’, 1.0),))
config(‘hfog’, ((‘r.LocalFogVolume.RenderDuringHeightFogPass’, 1),))
config(‘nohzb’, ((‘r.LocalFogVolume.UseHZB’, 0), (‘r.LocalFogVolume.TileCullingUseAsync’, 0)))
config(‘near’, ((‘r.LocalFogVolume.GlobalStartDistance’, 10.0), (‘r.LocalFogVolume.RenderIntoVolumetricFog’, 0)))
config(‘novf’, ((‘r.VolumetricFog’, 0),))
config(‘debugnovf’, ((‘r.VolumetricFog’, 0), (‘r.LocalFogVolume.TileDebug’, 2)))
print(‘BUILT voltest’)