Repository: khronosgroup/gltf-blender-io
Issue: Intensity units when exporting point lights 

Blender uses watts as units for power of point light sources.
On the other hand, KHR_punctual_lights use lm/sr as light intensity units.

I can see that values from point light power settings are **directly** written to gltf file without watts -> lumen -> candela conversion.



Changed files:
addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_lights.py, addons/io_scene_gltf2/__init__.py, addons/io_scene_gltf2/blender/com/gltf2_blender_conversion.py, addons/io_scene_gltf2/blender/imp/gltf2_blender_light.py

diff --git a/addons/io_scene_gltf2/__init__.py b/addons/io_scene_gltf2/__init__.py
index 9565ac2b..058d8add 100644
--- a/addons/io_scene_gltf2/__init__.py
+++ b/addons/io_scene_gltf2/__init__.py
@@ -109,7 +109,21 @@ def on_export_format_changed(self, context):
     )
 
 
-class ExportGLTF2_Base:
+class ConvertGLTF2_Base:
+    """Base class containing options that should be exposed during both import and export."""
+
+    convert_lighting_mode: EnumProperty(
+        name='Lighting Mode',
+        items=(
+            ('SPEC', 'Standard', 'Physically-based glTF lighting units (cd, lx, nt)'),
+            ('COMPAT', 'Unitless', 'Non-physical, unitless lighting. Useful when exposure controls are not available'),
+            ('RAW', 'Raw (Deprecated)', 'Blender lighting strengths with no conversion'),
+        ),
+        description='Optional backwards compatibility for non-standard render engines. Applies to lights',# TODO: and emissive materials',
+        default='SPEC'
+    )
+
+class ExportGLTF2_Base(ConvertGLTF2_Base):
     # TODO: refactor to avoid boilerplate
 
     def __init__(self):
@@ -654,6 +668,7 @@ class ExportGLTF2_Base:
             export_settings['gltf_morph_tangent'] = False
 
         export_settings['gltf_lights'] = self.export_lights
+        export_settings['gltf_lighting_mode'] = self.convert_lighting_mode
 
         export_settings['gltf_binary'] = bytearray()
         export_settings['gltf_binaryfilename'] = (
@@ -789,7 +804,7 @@ class GLTF_PT_export_transform(bpy.types.Panel):
 class GLTF_PT_export_geometry(bpy.types.Panel):
     bl_space_type = 'FILE_BROWSER'
     bl_region_type = 'TOOL_PROPS'
-    bl_label = "Geometry"
+    bl_label = "Data"
     bl_parent_id = "FILE_PT_operator"
     bl_options = {'DEFAULT_CLOSED'}
 
@@ -887,6 +902,28 @@ class GLTF_PT_export_geometry_original_pbr(bpy.types.Panel):
 
         layout.prop(operator, 'export_original_specular')
 
+class GLTF_PT_export_geometry_lighting(bpy.types.Panel):
+    bl_space_type = 'FILE_BROWSER'
+    bl_region_type = 'TOOL_PROPS'
+    bl_label = "Lighting"
+    bl_parent_id = "GLTF_PT_export_geometry"
+    bl_options = {'DEFAULT_CLOSED'}
+
+    @classmethod
+    def poll(cls, context):
+        sfile = context.space_data
+        operator = sfile.active_operator
+        return operator.bl_idname == "EXPORT_SCENE_OT_gltf"
+
+    def draw(self, context):
+        layout = self.layout
+        layout.use_property_split = True
+        layout.use_property_decorate = False  # No animation.
+
+        sfile = context.space_data
+        operator = sfile.active_operator
+
+        layout.prop(operator, 'convert_lighting_mode')
 
 class GLTF_PT_export_geometry_compression(bpy.types.Panel):
     bl_space_type = 'FILE_BROWSER'
@@ -1117,7 +1154,7 @@ def menu_func_export(self, context):
     self.layout.operator(ExportGLTF2.bl_idname, text='glTF 2.0 (.glb/.gltf)')
 
 
-class ImportGLTF2(Operator, ImportHelper):
+class ImportGLTF2(Operator, ConvertGLTF2_Base, ImportHelper):
     """Load a glTF 2.0 file"""
     bl_idname = 'import_scene.gltf'
     bl_label = 'Import glTF 2.0'
@@ -1200,6 +1237,7 @@ class ImportGLTF2(Operator, ImportHelper):
         layout.prop(self, 'import_shading')
         layout.prop(self, 'guess_original_bind_pose')
         layout.prop(self, 'bone_heuristic')
+        layout.prop(self, 'convert_lighting_mode')
 
     def invoke(self, context, event):
         import sys
@@ -1331,6 +1369,7 @@ classes = (
     GLTF_PT_export_geometry_mesh,
     GLTF_PT_export_geometry_material,
     GLTF_PT_export_geometry_original_pbr,
+    GLTF_PT_export_geometry_lighting,
     GLTF_PT_export_geometry_compression,
     GLTF_PT_export_animation,
     GLTF_PT_export_animation_export,
diff --git a/addons/io_scene_gltf2/blender/com/gltf2_blender_conversion.py b/addons/io_scene_gltf2/blender/com/gltf2_blender_conversion.py
index 7ddccafe..2cebedc6 100644
--- a/addons/io_scene_gltf2/blender/com/gltf2_blender_conversion.py
+++ b/addons/io_scene_gltf2/blender/com/gltf2_blender_conversion.py
@@ -16,6 +16,9 @@ from math import sin, cos
 import numpy as np
 from io_scene_gltf2.io.com import gltf2_io_constants
 
+PBR_WATTS_TO_LUMENS = 683
+# Industry convention, biological peak at 555nm, scientific standard as part of SI candela definition.
+
 def texture_transform_blender_to_gltf(mapping_transform):
     """
     Converts the offset/rotation/scale from a Mapping node applied in Blender's
diff --git a/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_lights.py b/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_lights.py
index 9614263c..d810307d 100644
--- a/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_lights.py
+++ b/addons/io_scene_gltf2/blender/exp/gltf2_blender_gather_lights.py
@@ -18,6 +18,7 @@ from typing import Optional, List, Dict, Any
 
 from io_scene_gltf2.blender.exp.gltf2_blender_gather_cache import cached
 from ..com.gltf2_blender_extras import generate_extras
+from ..com.gltf2_blender_conversion import PBR_WATTS_TO_LUMENS
 
 from io_scene_gltf2.io.com import gltf2_io_lights_punctual
 from io_scene_gltf2.io.com import gltf2_io_debug
@@ -61,7 +62,7 @@ def __gather_color(blender_lamp, export_settings) -> Optional[List[float]]:
     return list(blender_lamp.color)
 
 
-def __gather_intensity(blender_lamp, _) -> Optional[float]:
+def __gather_intensity(blender_lamp, export_settings) -> Optional[float]:
     emission_node = __get_cycles_emission_node(blender_lamp)
     if emission_node is not None:
         if blender_lamp.type != 'SUN':
@@ -79,9 +80,26 @@ def __gather_intensity(blender_lamp, _) -> Optional[float]:
                 emission_strength = blender_lamp.energy
         else:
             emission_strength = emission_node.inputs["Strength"].default_value
+    else:
+        emission_strength = blender_lamp.energy
+    if export_settings['gltf_lighting_mode'] == 'RAW':
         return emission_strength
-
-    return blender_lamp.energy
+    else:
+        # Assume at this point the computed strength is still in the appropriate watt-related SI unit, which if everything up to here was done with physical basis it hopefully should be.
+        if blender_lamp.type == 'SUN': # W/m^2 in Blender to lm/m^2 for GLTF/KHR_lights_punctual.
+            emission_luminous = emission_strength
+        else:
+            # Other than directional, only point and spot lamps are supported by GLTF.
+            # In Blender, points are omnidirectional W, and spots are specified as if they're points.
+            # Point and spot should both be lm/r^2 in GLTF.
+            emission_luminous = emission_strength / (4*math.pi)
+        if export_settings['gltf_lighting_mode'] == 'SPEC':
+            emission_luminous *= PBR_WATTS_TO_LUMENS
+        elif export_settings['gltf_lighting_mode'] == 'COMPAT':
+            pass # Just so we have an exhaustive tree to catch bugged values.
+        else:
+            raise ValueError(export_settings['gltf_lighting_mode'])
+        return emission_luminous
 
 
 def __gather_spot(blender_lamp, export_settings) -> Optional[gltf2_io_lights_punctual.LightSpot]:
diff --git a/addons/io_scene_gltf2/blender/imp/gltf2_blender_light.py b/addons/io_scene_gltf2/blender/imp/gltf2_blender_light.py
index 9e6b8a96..2f2f113d 100644
--- a/addons/io_scene_gltf2/blender/imp/gltf2_blender_light.py
+++ b/addons/io_scene_gltf2/blender/imp/gltf2_blender_light.py
@@ -17,6 +17,7 @@ from math import pi
 
 from ..com.gltf2_blender_extras import set_extras
 from io_scene_gltf2.io.imp.gltf2_io_user_extensions import import_user_extensions
+from ..com.gltf2_blender_conversion import PBR_WATTS_TO_LUMENS
 
 
 class BlenderLight():
@@ -32,7 +33,7 @@ class BlenderLight():
         import_user_extensions('gather_import_light_before_hook', gltf, vnode, pylight)
 
         if pylight['type'] == "directional":
-            light = BlenderLight.create_directional(gltf, light_id)
+            light = BlenderLight.create_directional(gltf, light_id) # ...Why not pass the pylight?
         elif pylight['type'] == "point":
             light = BlenderLight.create_point(gltf, light_id)
         elif pylight['type'] == "spot":
@@ -41,9 +42,6 @@ class BlenderLight():
         if 'color' in pylight.keys():
             light.color = pylight['color']
 
-        if 'intensity' in pylight.keys():
-            light.energy = pylight['intensity']
-
         # TODO range
 
         set_extras(light, pylight.get('extras'))
@@ -55,11 +53,33 @@ class BlenderLight():
         pylight = gltf.data.extensions['KHR_lights_punctual']['lights'][light_id]
 
         if 'name' not in pylight.keys():
-            pylight['name'] = "Sun"
+            pylight['name'] = "Sun" # Uh... Is it okay to mutate the import data?
 
         sun = bpy.data.lights.new(name=pylight['name'], type="SUN")
+
+        if 'intensity' in pylight.keys():
+            if gltf.import_settings['convert_lighting_mode'] == 'SPEC':
+                sun.energy = pylight['intensity'] / PBR_WATTS_TO_LUMENS
+            elif gltf.import_settings['convert_lighting_mode'] == 'COMPAT':
+                sun.energy = pylight['intensity']
+            elif gltf.import_settings['convert_lighting_mode'] == 'RAW':
+                sun.energy = pylight['intensity']
+            else:
+                raise ValueError(gltf.import_settings['convert_lighting_mode'])
+
         return sun
 
+    @staticmethod
+    def _calc_energy_pointlike(gltf, pylight):
+        if gltf.import_settings['convert_lighting_mode'] == 'SPEC':
+            return pylight['intensity'] / PBR_WATTS_TO_LUMENS * 4 * pi
+        elif gltf.import_settings['convert_lighting_mode'] == 'COMPAT':
+            return pylight['intensity'] * 4 * pi
+        elif gltf.import_settings['convert_lighting_mode'] == 'RAW':
+            return pylight['intensity']
+        else:
+            raise ValueError(gltf.import_settings['convert_lighting_mode'])
+
     @staticmethod
     def create_point(gltf, light_id):
         pylight = gltf.data.extensions['KHR_lights_punctual']['lights'][light_id]
@@ -68,6 +88,10 @@ class BlenderLight():
             pylight['name'] = "Point"
 
         point = bpy.data.lights.new(name=pylight['name'], type="POINT")
+
+        if 'intensity' in pylight.keys():
+            point.energy = BlenderLight._calc_energy_pointlike(gltf, pylight)
+
         return point
 
     @staticmethod
@@ -90,4 +114,7 @@ class BlenderLight():
         else:
             spot.spot_blend = 1.0
 
+        if 'intensity' in pylight.keys():
+            spot.energy = BlenderLight._calc_energy_pointlike(gltf, pylight)
+
         return spot