Add light softness: spotlight edge softness for physically-based rendering.

The new spotlight attribute softness (real in [0, 1], default 0) is the
fraction of the cone, measured inward from the cutoff, over which
intensity falls to zero. It is used by physically-based lighting models;
the Phong model's corresponding knob remains exponent.

The filament renderer previously hardcoded the inner cone angle to 0,
making the entire beam penumbra: the shader attenuates by the squared
smoothstep ((cos(theta) - cos(outer)) / (cos(inner) - cos(outer)))^2, so
a cutoff-25 spot delivered its rated candela only exactly on-axis and
about a third of it averaged over the light pool, with the deficit
shrinking as the cutoff widens. The inner angle is now
(1 - softness) * cutoff, so at the default the light delivers its full
intensity everywhere inside the cone and illuminance follows E = I/d^2
independent of the cutoff. Setting softness to 1 reproduces the previous
appearance exactly (verified bit-identical), which is the migration path
for models tuned against the old behavior.

The filament light type also changes from FOCUSED_SPOT to SPOT. With
intensity given in candela and the cone set at build time the two types
produce identical output (FOCUSED_SPOT's power-conserving rescale only
applies when the cone changes after the intensity is set), but SPOT
guarantees that candela never rescales with cone angle should the cone
ever become runtime-editable.

Verified with headless renders under a linear tone mapper against an
equal-candela point light at cutoffs 25/45/80: softness 0 gives
spot/point luminance ratio 1.000 at all sampled angles inside the cone;
softness 0.2 is flat over the inner 80% of the cone; softness 1 matches
the previous renderer with zero linear-pixel difference. XML round-trip
and the [0, 1] compile-time check verified. Introspect and wasm bindings
regenerated.

PiperOrigin-RevId: 959334706
Change-Id: I0f0729781899880de1729ea9b3d8c055d715a025
This commit is contained in:
Yuval Tassa
2026-08-04 18:11:03 -07:00
committed by Copybara-Service
parent 92261d9095
commit f9a00bd5b5
27 changed files with 118 additions and 15 deletions
+18 -4
View File
@@ -3307,18 +3307,30 @@ Attributes may be applied or ignored depending on the lighting model being used.
.. _body-light-attenuation:
:at:`attenuation`: :at-val:`real(3), "1 0 0"`
These are the constant, linear and quadratic attenuation coefficients for Phong lighting.
The default corresponds to no attenuation.
These are the constant, linear and quadratic attenuation coefficients, used by the default Phong lighting model.
The default corresponds to no attenuation. Physically-based lighting models instead attenuate with the inverse
square of distance, scaled by :ref:`intensity<body-light-intensity>` and limited by :ref:`range<body-light-range>`.
.. _body-light-cutoff:
:at:`cutoff`: :at-val:`real, "45"`
Cutoff angle for spotlights, always in degrees regardless of the global angle setting.
Cutoff angle for spotlights, always in degrees regardless of the global angle setting. The falloff of intensity
inside the cone is controlled by :ref:`softness<body-light-softness>` for physically-based lighting models and by
:ref:`exponent<body-light-exponent>` for the default Phong lighting model.
.. _body-light-softness:
:at:`softness`: :at-val:`real, "0"`
Edge softness for spotlights, as a fraction of the :ref:`cutoff<body-light-cutoff>` angle in [0, 1], used by
physically-based lighting models. The light delivers its full :ref:`intensity<body-light-intensity>` inside the
cone, falling to zero over the outer softness fraction of the cone angle; the default corresponds to a sharp-edged
cone. This is unused by the default Phong lighting model, which uses :ref:`exponent<body-light-exponent>`.
.. _body-light-exponent:
:at:`exponent`: :at-val:`real, "10"`
Exponent for spotlights. This setting controls the softness of the spotlight cutoff.
Exponent for spotlights, used by the default Phong lighting model. This setting controls the softness of the
spotlight cutoff. Physically-based lighting models use :ref:`softness<body-light-softness>` instead.
.. _body-composite:
@@ -10035,6 +10047,8 @@ if omitted.
.. _default-light-cutoff:
.. _default-light-softness:
.. _default-light-exponent:
.. _default-light-mode:
+6
View File
@@ -1163,6 +1163,9 @@
.. grid-item::
:ref:`cutoff<body-light-cutoff>`
.. grid-item::
:ref:`softness<body-light-softness>`
.. grid-item::
:ref:`exponent<body-light-exponent>`
@@ -5873,6 +5876,9 @@
.. grid-item::
:ref:`cutoff<default-light-cutoff>`
.. grid-item::
:ref:`softness<default-light-softness>`
.. grid-item::
:ref:`exponent<default-light-exponent>`
+15 -2
View File
@@ -13,8 +13,6 @@ General
grammar table, presence constraints, keyword maps, typed attribute bindings and save policies are generated from it
and gated by tests, as are the schema's enum keywords and declared defaults against the C headers and
default-constructors.
- An XSD schema derived from the main schema is provided in
`model/mjcf.xsd <https://github.com/google-deepmind/mujoco/tree/main/src/xml/generated/mjcf.xsd>`__.
Actuation
^^^^^^^^^
@@ -58,6 +56,21 @@ Models
Unlike the poncho models, which are bending-only, this model exercises the 2D
:ref:`stretch<flex-elasticity-elastic2d>` elasticity of a flex.
Rendering
^^^^^^^^^
.. admonition:: Breaking API changes
:class: attention
- Added :ref:`light/softness<body-light-softness>`: edge softness for spotlights under physically-based lighting
models, given as the fraction of the cone over which intensity falls to zero. The default of 0 is a sharp-edged
cone which delivers the full :ref:`intensity<body-light-intensity>` everywhere inside it, so that illuminance
follows :math:`E = I/d^2` independent of the :ref:`cutoff<body-light-cutoff>` angle. Previously the filament
renderer treated the entire cone as penumbra, dimming spotlights well below their rated intensity, increasingly
so for narrow cutoffs.
**Migration:** Set :at:`softness` to 1 to reproduce the previous appearance of existing models.
Bug fixes
^^^^^^^^^
+4
View File
@@ -860,6 +860,7 @@ typedef struct mjModel_ {
mjtNum* light_dir0; // global direction in qpos0 (nlight x 3)
float* light_attenuation; // OpenGL attenuation (quadratic model) (nlight x 3)
float* light_cutoff; // OpenGL cutoff (nlight x 1)
float* light_softness; // spotlight edge softness (nlight x 1)
float* light_exponent; // OpenGL exponent (nlight x 1)
float* light_ambient; // ambient rgb (alpha=1) (nlight x 3)
float* light_diffuse; // diffuse rgb (alpha=1) (nlight x 3)
@@ -1606,6 +1607,7 @@ typedef struct mjrfLightParams_ {
mjtBool cast_shadows; // if true, cast shadows
float range; // effective range of light, in meters
float spot_cone_angle; // spot light cone angle, in degrees
float spot_softness; // spot light edge softness, fraction of cone angle in [0, 1]
int shadow_map_size; // size of shadow map texture, 0 to use default size
float bulb_radius; // bulb radius, used for soft shadows
float vsm_blur_width; // variance shadow map blur width
@@ -1999,6 +2001,7 @@ typedef struct mjsLight_ { // light specification
float range; // range of effectiveness
float attenuation[3]; // OpenGL attenuation (quadratic model)
float cutoff; // OpenGL cutoff
float softness; // spotlight edge softness
float exponent; // OpenGL exponent
float ambient[3]; // ambient color
float diffuse[3]; // diffuse color
@@ -3291,6 +3294,7 @@ typedef struct mjvLight_ { // OpenGL light
float bulbradius; // bulb radius for soft shadows
float intensity; // intensity, in candelas
float range; // range of effectiveness
float softness; // spotlight edge softness
} mjvLight;
typedef struct mjvOption_ { // abstract visualization options
int label; // what objects to label (mjtLabel)
+1
View File
@@ -532,6 +532,7 @@ typedef struct mjModel_ {
mjtNum* light_dir0; // global direction in qpos0 (nlight x 3)
float* light_attenuation; // OpenGL attenuation (quadratic model) (nlight x 3)
float* light_cutoff; // OpenGL cutoff (nlight x 1)
float* light_softness; // spotlight edge softness (nlight x 1)
float* light_exponent; // OpenGL exponent (nlight x 1)
float* light_ambient; // ambient rgb (alpha=1) (nlight x 3)
float* light_diffuse; // diffuse rgb (alpha=1) (nlight x 3)
+1
View File
@@ -261,6 +261,7 @@ typedef struct mjrfLightParams_ {
mjtBool cast_shadows; // if true, cast shadows
float range; // effective range of light, in meters
float spot_cone_angle; // spot light cone angle, in degrees
float spot_softness; // spot light edge softness, fraction of cone angle in [0, 1]
int shadow_map_size; // size of shadow map texture, 0 to use default size
float bulb_radius; // bulb radius, used for soft shadows
float vsm_blur_width; // variance shadow map blur width
+1
View File
@@ -455,6 +455,7 @@ typedef struct mjsLight_ { // light specification
float range; // range of effectiveness
float attenuation[3]; // OpenGL attenuation (quadratic model)
float cutoff; // OpenGL cutoff
float softness; // spotlight edge softness
float exponent; // OpenGL exponent
float ambient[3]; // ambient color
float diffuse[3]; // diffuse color
+1
View File
@@ -258,6 +258,7 @@
X ( float, range, 1 ) \
XVEC( float, attenuation, 3 ) \
X ( float, cutoff, 1 ) \
X ( float, softness, 1 ) \
X ( float, exponent, 1 ) \
XVEC( float, ambient, 3 ) \
XVEC( float, diffuse, 3 ) \
+1
View File
@@ -277,6 +277,7 @@ typedef struct mjvLight_ { // OpenGL light
float bulbradius; // bulb radius for soft shadows
float intensity; // intensity, in candelas
float range; // range of effectiveness
float softness; // spotlight edge softness
} mjvLight;
+1
View File
@@ -439,6 +439,7 @@
X ( mjtNum, light_dir0, nlight, 3 ) \
X ( float, light_attenuation, nlight, 3 ) \
X ( float, light_cutoff, nlight, 1 ) \
X ( float, light_softness, nlight, 1 ) \
X ( float, light_exponent, nlight, 1 ) \
X ( float, light_ambient, nlight, 3 ) \
X ( float, light_diffuse, nlight, 3 ) \
+2 -1
View File
@@ -22,7 +22,8 @@
</asset>
<worldbody>
<light pos="1.5 -.5 2" dir="-.6 .2 -.75" diffuse=".7 .7 .7" intensity="200000"/>
<light pos="1.5 -.5 2" dir="-.6 .2 -.75" diffuse=".7 .7 .7" intensity="200000"
softness="1"/>
<light type="image" texture="ibl" intensity="10000"/>
<geom name="floor" type="plane" size="2 2 .01" rgba=".7 .75 .7 1"/>
+18
View File
@@ -2644,6 +2644,14 @@ STRUCTS: Mapping[str, StructDecl] = dict([
doc='OpenGL cutoff',
array_extent=('nlight',),
),
StructFieldDecl(
name='light_softness',
type=PointerType(
inner_type=ValueType(name='float'),
),
doc='spotlight edge softness',
array_extent=('nlight',),
),
StructFieldDecl(
name='light_exponent',
type=PointerType(
@@ -8411,6 +8419,11 @@ STRUCTS: Mapping[str, StructDecl] = dict([
type=ValueType(name='float'),
doc='OpenGL cutoff',
),
StructFieldDecl(
name='softness',
type=ValueType(name='float'),
doc='spotlight edge softness',
),
StructFieldDecl(
name='exponent',
type=ValueType(name='float'),
@@ -10633,6 +10646,11 @@ STRUCTS: Mapping[str, StructDecl] = dict([
type=ValueType(name='float'),
doc='range of effectiveness',
),
StructFieldDecl(
name='softness',
type=ValueType(name='float'),
doc='spotlight edge softness',
),
),
)),
('mjvOption',
+1
View File
@@ -1259,6 +1259,7 @@ This is useful for example when the MJB is not available as a file on disk.)"));
X(bulbradius);
X(intensity);
X(range);
X(softness);
#undef X
#define X(var) DefinePyArray(mjvLight, #var, &MjvLightWrapper::var)
+1
View File
@@ -2986,6 +2986,7 @@ void mjv_makeLights(const mjModel* m, const mjData* d, mjvScene* scn) {
f2f(thislight->attenuation, m->light_attenuation+3*i, 3);
thislight->exponent = m->light_exponent[i];
thislight->cutoff = m->light_cutoff[i];
thislight->softness = m->light_softness[i];
}
// copy colors
+10 -4
View File
@@ -60,7 +60,9 @@ Light::Light(filament::Engine* engine, const mjrfLightParams& params)
filament::LightManager::Type type;
switch (params.type) {
case mjLIGHT_SPOT:
type = filament::LightManager::Type::FOCUSED_SPOT;
// SPOT rather than FOCUSED_SPOT: MJCF intensity is candela, which must
// not rescale with the cone angle.
type = filament::LightManager::Type::SPOT;
break;
case mjLIGHT_DIRECTIONAL:
type = filament::LightManager::Type::DIRECTIONAL;
@@ -77,9 +79,13 @@ Light::Light(filament::Engine* engine, const mjrfLightParams& params)
builder.color(ReadFloat3(params.color));
builder.intensityCandela(params.intensity);
builder.castShadows(params.cast_shadows);
if (type == filament::LightManager::Type::FOCUSED_SPOT) {
builder.spotLightCone(0,
params.spot_cone_angle * std::numbers::pi / 180.0f);
if (type == filament::LightManager::Type::SPOT) {
// The light delivers its full intensity inside the inner cone, so that
// illuminance follows E = I/d^2, and falls to zero over the outer
// softness fraction of the cone angle.
const float outer = params.spot_cone_angle * std::numbers::pi / 180.0f;
const float inner = (1.0f - params.spot_softness) * outer;
builder.spotLightCone(inner, outer);
}
if (type != filament::LightManager::Type::DIRECTIONAL) {
builder.falloff(params.range);
+1
View File
@@ -85,6 +85,7 @@ void mjrf_defaultLightParams(mjrfLightParams* params) {
params->cast_shadows = true;
params->range = 10.0f;
params->spot_cone_angle = 180.f;
params->spot_softness = 0.0f;
params->bulb_radius = 0.0f;
params->shadow_map_size = 2048;
params->vsm_blur_width = 0.0f;
@@ -178,6 +178,7 @@ void ModelLights::Prepare() {
params.shadow_map_size = default_shadow_map_size;
if (params.type == mjLIGHT_SPOT) {
params.spot_cone_angle = model->light_cutoff[i];
params.spot_softness = model->light_softness[i];
}
auto light_obj = CreateLight(ctx, params);
+1
View File
@@ -3163,6 +3163,7 @@ void mjCModel::CopyTree(mjModel* m) {
m->light_range[lid] = pl->range;
mjuu_copyvec(m->light_attenuation+3*lid, pl->attenuation, 3);
m->light_cutoff[lid] = pl->cutoff;
m->light_softness[lid] = pl->softness;
m->light_exponent[lid] = pl->exponent;
mjuu_copyvec(m->light_ambient+3*lid, pl->ambient, 3);
mjuu_copyvec(m->light_diffuse+3*lid, pl->diffuse, 3);
+5
View File
@@ -4594,6 +4594,11 @@ void mjCLight::Compile(void) {
throw mjCError(this, "zero direction in light");
}
// check softness range
if (softness < 0 || softness > 1) {
throw mjCError(this, "light softness must be in [0, 1]");
}
// get targetbodyid and texid
ResolveReferences(model);
}
+2
View File
@@ -1495,6 +1495,7 @@
<xs:attribute name="range" type="xs:float" default="10"/>
<xs:attribute name="attenuation" type="float3" default="1 0 0"/>
<xs:attribute name="cutoff" type="xs:float" default="45"/>
<xs:attribute name="softness" type="xs:float" default="0"/>
<xs:attribute name="exponent" type="xs:float" default="10"/>
<xs:attribute name="ambient" type="float3"/>
<xs:attribute name="diffuse" type="float3" default="0.7 0.7 0.7"/>
@@ -2273,6 +2274,7 @@
<xs:attribute name="range" type="xs:float" default="10"/>
<xs:attribute name="attenuation" type="float3" default="1 0 0"/>
<xs:attribute name="cutoff" type="xs:float" default="45"/>
<xs:attribute name="softness" type="xs:float" default="0"/>
<xs:attribute name="exponent" type="xs:float" default="10"/>
<xs:attribute name="ambient" type="float3"/>
<xs:attribute name="diffuse" type="float3" default="0.7 0.7 0.7"/>
+1
View File
@@ -394,6 +394,7 @@ static const mjXDefaultEntry kDefaults_mjsLight[] = {
{"range", (int)offsetof(mjsLight, range), 1, 1, 1, 0, {10.0}},
{"attenuation", (int)offsetof(mjsLight, attenuation), 1, 3, 3, 0, {1.0, 0.0, 0.0}},
{"cutoff", (int)offsetof(mjsLight, cutoff), 1, 1, 1, 0, {45.0}},
{"softness", (int)offsetof(mjsLight, softness), 1, 1, 1, 0, {0.0}},
{"exponent", (int)offsetof(mjsLight, exponent), 1, 1, 1, 0, {10.0}},
{"ambient", (int)offsetof(mjsLight, ambient), 1, 3, 0, 0, {0}},
{"diffuse", (int)offsetof(mjsLight, diffuse), 1, 3, 3, 0, {0.7, 0.7, 0.7}},
+1
View File
@@ -426,6 +426,7 @@ inline constexpr mjXAttr kLightAttrs[] = {
{"range", mjXAttr::kFloat, 1, true, false, false, false, (int)offsetof(mjsLight, range)},
{"attenuation", mjXAttr::kFloat, 3, true, false, false, false, (int)offsetof(mjsLight, attenuation)},
{"cutoff", mjXAttr::kFloat, 1, true, false, false, false, (int)offsetof(mjsLight, cutoff)},
{"softness", mjXAttr::kFloat, 1, true, false, false, false, (int)offsetof(mjsLight, softness)},
{"exponent", mjXAttr::kFloat, 1, true, false, false, false, (int)offsetof(mjsLight, exponent)},
{"ambient", mjXAttr::kFloat, 3, true, false, false, false, (int)offsetof(mjsLight, ambient)},
{"diffuse", mjXAttr::kFloat, 3, true, false, false, false, (int)offsetof(mjsLight, diffuse)},
+4 -4
View File
@@ -92,8 +92,8 @@ std::vector<const char*> MJCF[] = {
"axisangle", "xyaxes", "zaxis", "euler", "mode", "focal", "focalpixel", "principal",
"principalpixel", "sensorsize", "user"},
{"light", "?", "directional", "type", "castshadow", "active", "pos", "dir", "bulbradius",
"intensity", "range", "attenuation", "cutoff", "exponent", "ambient", "diffuse",
"specular", "mode"},
"intensity", "range", "attenuation", "cutoff", "softness", "exponent", "ambient",
"diffuse", "specular", "mode"},
{"pair", "?", "condim", "friction", "solref", "solreffriction", "solimp", "gap", "margin",
"adhesion"},
{"equality", "?", "active", "solref", "solimp"},
@@ -204,8 +204,8 @@ std::vector<const char*> MJCF[] = {
"pos", "quat", "axisangle", "xyaxes", "zaxis", "euler", "mode", "target", "focal",
"focalpixel", "principal", "principalpixel", "sensorsize", "user"},
{"light", "*", "name", "class", "directional", "type", "castshadow", "active", "pos",
"dir", "bulbradius", "intensity", "range", "attenuation", "cutoff", "exponent",
"ambient", "diffuse", "specular", "mode", "target", "texture"},
"dir", "bulbradius", "intensity", "range", "attenuation", "cutoff", "softness",
"exponent", "ambient", "diffuse", "specular", "mode", "target", "texture"},
{"plugin", "*", "plugin", "instance"},
{"<"},
{"config", "*", "key", "value"},
+1
View File
@@ -1117,6 +1117,7 @@ element light : mjsLight {
range : float = 10
attenuation : float[3] = {1, 0, 0}
cutoff : float = 45
softness : float = 0
exponent : float = 10
ambient : float[3]
diffuse : float[3] = {0.7, 0.7, 0.7}
+2
View File
@@ -1286,6 +1286,7 @@ public unsafe struct mjModel_ {
public double* light_dir0;
public float* light_attenuation;
public float* light_cutoff;
public float* light_softness;
public float* light_exponent;
public float* light_ambient;
public float* light_diffuse;
@@ -6030,6 +6031,7 @@ public unsafe struct mjvLight_ {
public float bulbradius;
public float intensity;
public float range;
public float softness;
}
[StructLayout(LayoutKind.Sequential)]
+3
View File
@@ -5102,6 +5102,7 @@ EMSCRIPTEN_BINDINGS(mujoco_bindings) {
.property("light_pos0", &MjModel::light_pos0)
.property("light_poscom0", &MjModel::light_poscom0)
.property("light_range", &MjModel::light_range)
.property("light_softness", &MjModel::light_softness)
.property("light_specular", &MjModel::light_specular)
.property("light_targetbodyid", &MjModel::light_targetbodyid)
.property("light_texid", &MjModel::light_texid)
@@ -5893,6 +5894,7 @@ EMSCRIPTEN_BINDINGS(mujoco_bindings) {
.property("mode", &MjsLight::mode, &MjsLight::set_mode, reference())
.property("pos", &MjsLight::pos)
.property("range", &MjsLight::range, &MjsLight::set_range, reference())
.property("softness", &MjsLight::softness, &MjsLight::set_softness, reference())
.property("specular", &MjsLight::specular)
.property("targetbody", &MjsLight::targetbody, &MjsLight::set_targetbody, reference())
.property("texture", &MjsLight::texture, &MjsLight::set_texture, reference())
@@ -6170,6 +6172,7 @@ EMSCRIPTEN_BINDINGS(mujoco_bindings) {
.property("intensity", &MjvLight::intensity, &MjvLight::set_intensity, reference())
.property("pos", &MjvLight::pos)
.property("range", &MjvLight::range, &MjvLight::set_range, reference())
.property("softness", &MjvLight::softness, &MjvLight::set_softness, reference())
.property("specular", &MjvLight::specular)
.property("texid", &MjvLight::texid, &MjvLight::set_texid, reference())
.property("type", &MjvLight::type, &MjvLight::set_type, reference());
+15
View File
@@ -2033,6 +2033,12 @@ struct MjvLight {
void set_range(float value) {
ptr_->range = value;
}
float softness() const {
return ptr_->softness;
}
void set_softness(float value) {
ptr_->softness = value;
}
private:
mjvLight* ptr_;
@@ -2919,6 +2925,12 @@ struct MjsLight {
void set_cutoff(float value) {
ptr_->cutoff = value;
}
float softness() const {
return ptr_->softness;
}
void set_softness(float value) {
ptr_->softness = value;
}
float exponent() const {
return ptr_->exponent;
}
@@ -4785,6 +4797,9 @@ struct MjModel {
emscripten::val light_cutoff() const {
return emscripten::val(emscripten::typed_memory_view(ptr_->nlight, ptr_->light_cutoff));
}
emscripten::val light_softness() const {
return emscripten::val(emscripten::typed_memory_view(ptr_->nlight, ptr_->light_softness));
}
emscripten::val light_exponent() const {
return emscripten::val(emscripten::typed_memory_view(ptr_->nlight, ptr_->light_exponent));
}