Add Filament lighting and shadow features and fixes.

- Add a hidden directional light to Filament scenes with no lights to work around soft shadow blackout bug.
- Respect quality/shadowsize as default shadow map resolution.
- Interpret light/bulbradius in correct units for each shadow type and update XML reference docs.
- Support updating the shadow map resolution of live Filament lights.
- Clamp light intensities to non-negative values in the Filament debug panel.

PiperOrigin-RevId: 957305808
Change-Id: Ic7a3430aaa3b117be7fbb7ace836d68e54cdf65a
This commit is contained in:
Yuval Tassa
2026-07-31 13:27:31 -07:00
committed by Copybara-Service
parent 0ca53dacf8
commit 7bc1aa9b05
11 changed files with 141 additions and 13 deletions
+9
View File
@@ -3429,6 +3429,15 @@ Enables or disables the light.
Sets the intensity of the light, in candela.
.. _mjrf_setLightShadowMapSize:
`mjrf_setLightShadowMapSize <#mjrf_setLightShadowMapSize>`__
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
.. mujoco-include:: mjrf_setLightShadowMapSize
Sets the resolution of the light's shadow map, in texels.
.. _mjrf_setLightColor:
`mjrf_setLightColor <#mjrf_setLightColor>`__
+2 -2
View File
@@ -3301,8 +3301,8 @@ Attributes may be applied or ignored depending on the lighting model being used.
.. _body-light-bulbradius:
:at:`bulbradius`: :at-val:`real, "0.02"`
The radius of the light source which can affect shadow softness depending on the
renderer. This only applies to spotlights.
The radius of the light-emitting surface. Larger radii produce softer shadows in renderers that
support soft shadows. Ignored by the classic renderer.
.. _body-light-attenuation:
+1
View File
@@ -3442,6 +3442,7 @@ mjrfLight* mjrf_createLight(mjrfContext* ctx, const mjrfLightParams* params);
void mjrf_destroyLight(mjrfLight* light);
void mjrf_setLightEnabled(mjrfLight* light, mjtBool enabled);
void mjrf_setLightIntensity(mjrfLight* light, float intensity);
void mjrf_setLightShadowMapSize(mjrfLight* light, int map_size);
void mjrf_setLightColor(mjrfLight* light, const float color[3]);
void mjrf_setLightTransform(mjrfLight* light, const float position[3], const float direction[3]);
int mjrf_getLightType(const mjrfLight* light);
+3
View File
@@ -281,6 +281,9 @@ void mjrf_setLightEnabled(mjrfLight* light, mjtBool enabled);
// Sets the intensity of the light, in candela.
void mjrf_setLightIntensity(mjrfLight* light, float intensity);
// Sets the resolution of the light's shadow map, in texels.
void mjrf_setLightShadowMapSize(mjrfLight* light, int map_size);
// Sets the RGB color of the light.
void mjrf_setLightColor(mjrfLight* light, const float color[3]);
@@ -100,6 +100,8 @@ mat4 CalculateClipFromWorld(const mjrRect& viewport, const mjrCamera& cam) {
void SceneBridge::Update(const mjrRect& viewport, const mjvScene* scene) {
const mjModel* model = model_objects_->GetModel();
model_lights_->Update();
mjtNum hpos[3], hfwd[3];
float headpos[3], gazedir[3];
mjv_cameraInModel(hpos, hfwd, nullptr, scene);
+15 -2
View File
@@ -187,6 +187,19 @@ bool Ui(std::string_view label, T* value, UiOpts<T> opts = {}) {
static_assert(dependent_false<T>::value, "Unsupported type");
}
// The Drag widgets above do not enforce bounds when only one of min/max is
// given, and typed input (ctrl+click) bypasses Slider bounds; clamp here.
if constexpr (std::is_arithmetic_v<T> && !std::is_same_v<T, bool>) {
if (changed) {
if (opts.min.has_value()) {
*value = std::max(*value, *opts.min);
}
if (opts.max.has_value()) {
*value = std::min(*value, *opts.max);
}
}
}
if (modified) {
ImGui::PopStyleColor();
ImGui::SetItemTooltip("RMB to revert change");
@@ -565,7 +578,7 @@ void DrawIndirectLightGui(SceneView* scene_view) {
float intensity = 10000.0f;
if (ibl) {
intensity = ibl->getIntensity();
if (Ui("Intensity", &intensity, {.step = 1000.0f, .fstep = 10000.0f})) {
if (Ui("Intensity", &intensity, {.min = 0.0f, .step = 1000.0f, .fstep = 10000.0f})) {
ibl->setIntensity(intensity);
}
}
@@ -586,7 +599,7 @@ void DrawLightGui(filament::LightManager& lm,
lm.setDirection(li, direction);
}
float intensity = lm.getIntensity(li);
if (Ui("Intensity", &intensity, {.step = 1000.0f, .fstep = 10000.0f})) {
if (Ui("Intensity", &intensity, {.min = 0.0f, .step = 1000.0f, .fstep = 10000.0f})) {
lm.setIntensityCandela(li, intensity);
}
float falloff = lm.getFalloff(li);
+12
View File
@@ -165,6 +165,18 @@ void Light::SetIntensity(float intensity) {
}
}
void Light::SetShadowMapSize(int map_size) {
params_.shadow_map_size = map_size;
if (ibl_) {
return;
}
filament::LightManager& lm = engine_->getLightManager();
const filament::LightManager::Instance li = lm.getInstance(entity_);
filament::LightManager::ShadowOptions opts = lm.getShadowOptions(li);
opts.mapSize = map_size;
lm.setShadowOptions(li, opts);
}
void Light::Enable() {
if (!enabled_) {
enabled_ = true;
+3
View File
@@ -51,6 +51,9 @@ class Light : public mjrfLight {
// Sets the intensity of this light, in candela.
void SetIntensity(float intensity);
// Sets the resolution of this light's shadow map, in texels.
void SetShadowMapSize(int map_size);
// Returns the type of the light.
mjtLightType GetType() const;
+4
View File
@@ -234,6 +234,10 @@ void mjrf_setLightIntensity(mjrfLight* light, float intensity) {
mujoco::Light::downcast(light)->SetIntensity(intensity);
}
void mjrf_setLightShadowMapSize(mjrfLight* light, int map_size) {
mujoco::Light::downcast(light)->SetShadowMapSize(map_size);
}
void mjrf_setLightColor(mjrfLight* light, const float color[3]) {
mujoco::Light::downcast(light)->SetColor({color[0], color[1], color[2]});
}
+82 -7
View File
@@ -14,10 +14,13 @@
#include "render/filament/support/model_lights.h"
#include <algorithm>
#include <memory>
#include <numbers>
#include <string>
#include <utility>
#include <math/TVecHelpers.h>
#include <math/mat4.h>
#include <math/mathfwd.h>
#include <math/vec3.h>
@@ -74,8 +77,7 @@ static UniquePtr<mjrfTexture> CreateFallbackIndirectLightTexture(
ModelLights::ModelLights(mjrfScene* scene, ModelObjects* model_objects)
: scene_(scene), model_objects_(model_objects) {
const mjModel* model = model_objects->GetModel();
default_shadow_map_size_ =
ReadElement(model, "filament.shadows.map_size", default_shadow_map_size_);
shadowsize_ = model->vis.quality.shadowsize;
fallback_head_light_intensity_ =
ReadElement(model, "filament.fallback.head_light_intensity",
fallback_head_light_intensity_);
@@ -97,16 +99,28 @@ ModelLights::~ModelLights() {
mjrf_removeLightFromScene(scene_, fallback_ibl_.get());
}
fallback_ibl_.reset();
if (fallback_directional_) {
mjrf_removeLightFromScene(scene_, fallback_directional_.get());
}
fallback_directional_.reset();
}
void ModelLights::Prepare() {
mjrfContext* ctx = model_objects_->GetContext();
const mjModel* model = model_objects_->GetModel();
// Default to the shadow map resolution in mjVisual, like the classic
// renderer. Filament clamps shadow map sizes to 2048.
int default_shadow_map_size = std::min(model->vis.quality.shadowsize, 2048);
default_shadow_map_size =
ReadElement(model, "filament.shadows.map_size", default_shadow_map_size);
bool has_image_based_light = false;
bool has_directional_light = false;
float total_light_intensity = 0.0f;
for (int i = 0; i < model->nlight; ++i) {
total_light_intensity += model->light_intensity[i];
has_directional_light |= model->light_type[i] == mjLIGHT_DIRECTIONAL;
if (model->light_type[i] == mjLIGHT_IMAGE) {
mjrfLightParams params;
@@ -126,13 +140,42 @@ void ModelLights::Prepare() {
params.color[2] = model->light_diffuse[2];
params.type = (mjtLightType)model->light_type[i];
params.cast_shadows = model->light_castshadow[i];
// The bulb_radius is only used by DPCF or PCSS shadows. For VSM shadows,
// we use light_bulbradius to control the blur width.
params.bulb_radius = model->light_bulbradius[i];
params.vsm_blur_width = model->light_bulbradius[i];
// light_bulbradius is the radius of the emitting surface in meters.
// Filament's DPCF/PCSS internally scale shadowBulbRadius from meters
// to light-space texels, so we pass it through in meters for all types.
// VSM requires a single uniform blur width for the map; we approximate
// this using the angular size of the bulb from the scene center.
const float bulb_radius = model->light_bulbradius[i];
const float map_size = default_shadow_map_size;
const float3 to_center = ReadFloat3(model->stat.center) -
ReadFloat3(model->light_pos0, i);
const float distance = std::max(length(to_center), 1e-6f);
const float bulb_angle = bulb_radius / distance;
params.bulb_radius = bulb_radius;
switch (params.type) {
case mjLIGHT_SPOT: {
const float fov =
2.0f * model->light_cutoff[i] * std::numbers::pi / 180.0f;
params.vsm_blur_width = bulb_angle * map_size / fov;
break;
}
case mjLIGHT_POINT:
params.vsm_blur_width =
bulb_angle * map_size / (0.5f * std::numbers::pi);
break;
case mjLIGHT_DIRECTIONAL: {
const float coverage =
2.0f * model->vis.map.shadowclip * model->stat.extent;
params.vsm_blur_width = bulb_radius * map_size / coverage;
break;
}
default:
break;
}
params.vsm_blur_width = std::min(params.vsm_blur_width, 125.0f);
params.range = model->light_range[i];
params.intensity = model->light_intensity[i];
params.shadow_map_size = default_shadow_map_size_;
params.shadow_map_size = default_shadow_map_size;
if (params.type == mjLIGHT_SPOT) {
params.spot_cone_angle = model->light_cutoff[i];
}
@@ -162,6 +205,23 @@ void ModelLights::Prepare() {
lights_.emplace_back(std::move(light_obj));
}
// Workaround for an upstream filament bug, present since 1.74.0
// (https://github.com/google/filament/issues/10249): under the non-PCF
// shadow types (VSM/DPCF/PCSS), a scene where a punctual (spot/point) light
// casts shadows renders fully black unless a directional light is also
// present. Mere presence suffices: zero intensity, shadows off. Keep
// filament's directional slot occupied with an invisible light whenever the
// model has no directional light.
if (!has_directional_light) {
mjrfLightParams params;
mjrf_defaultLightParams(&params);
params.type = mjLIGHT_DIRECTIONAL;
params.cast_shadows = 0;
params.intensity = 0.0f;
fallback_directional_ = CreateLight(ctx, params);
mjrf_addLightToScene(scene_, fallback_directional_.get());
}
if (!has_image_based_light && total_light_intensity > 0.0f) {
// Create a black indirect light to ensure that the skybox is
// oriented to respect mujoco's Z-up convention.
@@ -203,7 +263,22 @@ void ModelLights::Prepare() {
mjrf_setSceneSkybox(scene_, model_objects_->GetSkyboxTexture());
}
void ModelLights::UpdateShadowMapSize() {
const mjModel* model = model_objects_->GetModel();
if (model->vis.quality.shadowsize != shadowsize_) {
shadowsize_ = model->vis.quality.shadowsize;
const int map_size = std::min(shadowsize_, 2048);
for (auto& light : lights_) {
mjrf_setLightShadowMapSize(light.get(), map_size);
}
}
}
void ModelLights::Update(const mjData* data) {
UpdateShadowMapSize();
if (data == nullptr) {
return;
}
const mjModel* model = model_objects_->GetModel();
for (int i = 0; i <= model->nlight; ++i) {
// Light with index nlight is the headlight.
+8 -2
View File
@@ -31,7 +31,7 @@ class ModelLights {
~ModelLights();
// Updates the state of the lights in the scene.
void Update(const mjData* data);
void Update(const mjData* data = nullptr);
// Returns the light with the given index in the mjModel. Note that an extra
// headlight is assigned of the index `nlight`.
@@ -43,13 +43,19 @@ class ModelLights {
private:
void Prepare();
// Applies changes of mjVisual quality.shadowsize to the lights' shadow
// maps. A filament.shadows.map_size custom element remains in effect until
// shadowsize is edited.
void UpdateShadowMapSize();
mjrfScene* scene_ = nullptr;
ModelObjects* model_objects_ = nullptr;
UniquePtr<mjrfLight> fallback_ibl_{nullptr, nullptr};
UniquePtr<mjrfLight> fallback_directional_{nullptr, nullptr};
UniquePtr<mjrfTexture> fallback_ibl_texture_{nullptr, nullptr};
std::vector<UniquePtr<mjrfLight>> lights_;
int default_shadow_map_size_ = 2048;
int shadowsize_ = 0;
float fallback_head_light_intensity_ = 0.f;
float fallback_scene_light_intensity_ = 80'000.f;
float fallback_environment_light_intensity_ = 5'000.f;