google / model-viewer

Easily display interactive 3D models on the web and in AR!
https://modelviewer.dev
Apache License 2.0
6.9k stars 816 forks source link

Add V-Ray CPU to the render fidelity test #4483

Closed bhouston closed 1 year ago

bhouston commented 1 year ago

Description

We should add V-Ray to the model-viewer render fidelity page. This will encourage improved interoperability of the Khronos PBR BDSF with the V-Ray ecosystem.

There is an existing Python project for rendering glTFs in V-Ray here: https://github.com/ChaosGroup/vray_gltf. It is a couple years old so I believe it doesn't support the latest extensions, but maybe we can start with it.

We should likely take this existing python project and modify it to load the relevant models, cameras and environments used by the model-viewer fidelity test framework, and then submit the updated code and relevant images to this repository, allowing us to see what works and what doesn't work in V-Ray.

This can then drive further updates to the V-Ray test script (probably adding support for modern glTF extensions) or to this integration test suite.

I am willing to fund this on behalf of Threekit.

Live Demo

Not relevant.

Version

Not relevant.

Browser Affected

Not relevant.

OS

Not relevant.

AR

Not relevant.

bhouston commented 1 year ago

Acceptance criteria:

jasondavies commented 1 year ago

I'll have a go at this.

jasondavies commented 1 year ago

Any idea what the default camera settings should be when not specified by the glTF model?

The following options are supported by vray_gltf:

  --default_cam_look_at DEFAULT_CAM_LOOK_AT
                        Camera look at (x,y,z)
  --default_cam_rot DEFAULT_CAM_ROT
                        Default camera rotation(degrees) (x,y,z) or "(x,y,z)" around the avarage object position of the scene, other brackets work too
  --default_cam_moffset DEFAULT_CAM_MOFFSET
                        Default camera multiplier offset (x,y,z) or "(x,y,z)", all brackets will work
  --default_cam_pos DEFAULT_CAM_POS
                        Default camera Pos (x,y,z) or "(x,y,z)", other default cam still work but relative on this position
  --default_cam_fov DEFAULT_CAM_FOV
                        Default camera FOV is degrees
  --default_cam_zoom DEFAULT_CAM_ZOOM
                        Default camera Zoom -inf to 1.0 as 1.0 max zoom
  --default_cam_view DEFAULT_CAM_VIEW
                        Default camera view, one of front, back, left, right, top, bottom or auto
jasondavies commented 1 year ago

I'm guessing the orbit property defines the camera rotation, for each scenario.

bhouston commented 1 year ago

I notice there are other renderer specific hardnesses that seem to have camera code in them, like this one:

https://github.com/google/model-viewer/blob/master/packages/render-fidelity-tools/src/components/renderers/rhodonite-viewer.ts#L162

I suspect that is a guide to calculating the camera matrix from the harness information?

I think both V-Ray and Blender Cycles are problematic to integrate into this test suite. Maybe one could create a mini-web server that just has a post that the harness calls with the information and then that single route passes the information to python to do the render and returns the result? Then it still run within the harness?

bhouston commented 1 year ago

I could write a webserver tomorrow morning for this purpose? Would that help you?

jasondavies commented 1 year ago

The most important issue right now is translating the scenario camera information to V-Ray's transform, which I believe is a rotation matrix and a translation matrix. I'm going to bed now, but will look at this tomorrow morning.

Regarding the test harness, let's figure that out once we have at least one render scenario working. I was planning to use the offline rendering path anyway for the initial version.

bhouston commented 1 year ago

The code I linked to in my earlier comment calculates the matrix position (eye) and direction/up manually. From direction and up you get right/left via cross product and then you can use that to set the basis.

jasondavies commented 1 year ago

Success:

test

Compare to three-gpu-pathtracer-golden:

three-gpu-pathtracer-golden

jasondavies commented 1 year ago

Note that vray_gltf supports a fairly limited set of extensions:

Textures:

Materials:

Lights:

jasondavies commented 1 year ago

Using the above extension list, it seems the following scenarios are unsupported (list of unsupported extensions in brackets):

bhouston commented 1 year ago

The KHR_material_variants isn't really a visual extension, rather it is about the loader switching the glTF materials or nodes around.

These are visual ones that are missing in order of descending importance:

I personally think this one isn't important and it also may not be feasible in most pathtracers, rather implementing it is figuring out how to get around all of the path tracer features:

bhouston commented 1 year ago

Amazing work! I notice the background is different.

How different are the images that do not use the unsupported extensions?

I would love to see how close is the match when we know all features are supposed to be supported?

bhouston commented 1 year ago

I think the HDR light intensity or the tone mapping is different as well. I wonder if it is even possible to have the tone mapping match? I suspect it may not be possible as the tone mapping operators in V-Ray may different from those available in Three.js.

jasondavies commented 1 year ago

The render above was done without any HDR environment (looks like most scenarios should use lightroom_14b.hdr if nothing is specified). The background colour was originally black by default (but now fixed to white).

I managed to load lightroom_14b.hdr and convert to V-Ray's LightDome but I think it's not working quite right yet (maybe incorrect intensity?):

test

bhouston commented 1 year ago

I think that usually in these tests the HDR intensity isn't adjusted and often it just uses what is in the file directly. Output intensities may be affected by the tone mapping?

bhouston commented 1 year ago

I also notice now that the very recent KHR_materials_anisotropy isn't supported by either the fidelity test framework or the vray_gltf project.

jasondavies commented 1 year ago

OK, finally figured out the UVW transform required for the default HDR. Compare:

V-Ray:

vray-golden

Stellar:

stellar-golden

It still feels like intensity is off, but at least it's oriented correctly.

jasondavies commented 1 year ago

Here's the command I'm currently generating for khronos-Fox:

python3 main.py /Users/jason/tmp/model-viewer/packages/shared-assets/models/glTF-Sample-Models/2.0/Fox/glTF/Fox.gltf --output_file /Users/jason/tmp/model-viewer/packages/render-fidelity-tools/test/goldens/khronos-Fox/vray-golden.png --render_mode production --default_cam_rot '(-60,0,0)' --default_cam_look_at '(-35,37,25)' --default_cam_pos '(0,0,124)' --size '(1536,1536)' --num_frames 1

Here's the JSON config:

    {
      "name": "khronos-Fox",
      "model": "../../../shared-assets/models/glTF-Sample-Models/2.0/Fox/glTF/Fox.gltf",
      "target": {
        "y": 37,
        "x": -35,
        "z": 25
      },
      "orbit": {
        "theta": -60,
        "radius": 124
      },
      "exclude": [
        "stellar"
      ]
    }

I think there's an issue with --default_cam_look_at (or how I'm using it).

You'll need to use my vray_gltf branch.

bsdorra commented 1 year ago

A while ago I added some code to the render-fidelity-tools that allows for an easy integration of external renderers with the existing pipeline. It's being used to generate the path-traced DS Stellar renderings.

An external renderer can simply be added to the renderer list in the test config like this:

{
  "renderers": [
    {
      "name": "stellar",
      "description": "Dassault Systèmes STELLAR",
      "command": {
        "executable": "python",
        "args": [
          "test/renderers/stellar/stellar.py"
        ]
      }
    }
  ]
}

On `npm run update-screenshots', the test suite calls the provided command with a single argument, that is the stringified json object containing the scenario config (the same info that is provided to the web rendering backends).

The python main then looks looks like this

def main():
    """cmd render script

      ARGS:
          argv[1] (str): The stringified json object containing scenario config and outputPath properties
          Example:
          {
            "scenario": {
              "lighting": "../../../shared-assets/environments/lightroom_14b.hdr",
              "dimensions": {
                "width": 768,
                "height": 450
              },
              "target": {
                "x": 0,
                "y": 0.3,
                "z": 0
              },
              "orbit": {
                "theta": 0,
                "phi": 90,
                "radius": 1
              },
              "verticalFoV": 45,
              "renderSkybox": False,
              "name": "khronos-SheenChair",
              "model": "../../../shared-assets/models/glTF-Sample-Models/2.0/SheenChair/glTF-Binary/SheenChair.glb"
            },
            "outputFile": "../../../test/goldens/khronos-SheenChair/stellar-golden.png"
          }
    """
    config = json.loads(sys.argv[1])

    scenario = config["scenario"]
    outpath = config["outputFile"]

    # parse scenario
    resolution = (scenario["dimensions"]["width"], scenario["dimensions"]["height"])
    scenePath = "shared-assets" + scenario["model"].split("shared-assets")[1]
    iblPath = "shared-assets" + scenario["lighting"].split("shared-assets")[1]
    renderSkybox = scenario["renderSkybox"]

    target = np.array([scenario["target"]["x"], scenario["target"]["y"], scenario["target"]["z"]])
    theta = scenario["orbit"]["theta"]
    phi = scenario["orbit"]["phi"]
    radius = scenario["orbit"]["radius"]
    verticalFov = scenario["verticalFoV"]
    aspect = resolution[0]/resolution[1]

    # setup scene
    scene = load_scene(scenePath)
    camera = create_camera(scene, target, verticalFov, aspect, theta, phi, radius)
    ibl = create_hdri_light(scene, iblPath, renderSkybox)

    #render
    beauty_image = render_scene(config, scene, renderer, camera, ibl, NUM_SAMPLES)

    # tonemap
    beauty_image[:,:,:3] *= 1.0 / 0.6;
    beauty_image[:,:,:3] = ACESFilmicToneMapping(beauty_image[:,:,:3])
    # gamma
    beauty_image[:,:,:3] = np.power(np.clip(beauty_image[:,:,:3], 0.0, 0.9999), 1.0/2.2)

    if renderSkybox:
      beauty_ldr = (beauty_image[:, :, :3] * 255).astype(np.uint8)
    else:
      beauty_ldr = (beauty_image * 255).astype(np.uint8)

    save_image(os.path.join("./", outpath), beauty_ldr)

Test scenarios can be disabled for a renderer by adding the renderer's name to the scenario's exclude list in the test config. E.g.

    {
      "name": "khronos-IridescenceDielectricSpheres",
      "model": "../../../shared-assets/models/glTF-Sample-Models/2.0/IridescenceDielectricSpheres/glTF/IridescenceDielectricSpheres.gltf",
      "orbit": {
        "radius": 50,
        "theta": 45,
        "phi": 65
      },
      "target": {
        "y": -3
      },
      "dimensions": {
        "height": 700,
        "width": 700
      },
      "exclude": [
        "stellar"
      ]
    }
bhouston commented 1 year ago

@jasondavies I notice the ACESFilmicToneMapping python call in @bsdorra's script above. We likely have to have V-Ray render to a linear HDR image and then apply that tone mapping as he did. I think it can be hard to have V-Ray do this properly.

bhouston commented 1 year ago

@bsdorra do you have the code available for the ACESFilmicToneMapping function you used?

bsdorra commented 1 year ago

@bhouston sure


def RRTAndODTFit( v: np.array ):
  a = v * ( v + 0.0245786 ) - 0.000090537;
  b = v * ( 0.983729 * v + 0.4329510 ) + 0.238081;
  return a / b;

def ACESFilmicToneMapping(img: np.array) -> np.array:
  """Using the same tonemapping function as three.js and glTF Sample Viewer
  https://github.com/mrdoob/three.js/blob/dev/examples/jsm/shaders/ACESFilmicToneMappingShader.js

  Args:
      img (np.array): The HDR image to be tonemapped

  Returns:
      np.array: The tonemapped LDR image
  """
  # sRGB => XYZ => D65_2_D60 => AP1 => RRT_SAT
  ACESInputMat = np.array([
    [0.59719, 0.07600, 0.02840], # transposed from source
    [0.35458, 0.90834, 0.13383],
    [0.04823, 0.01566, 0.83777],
  ])

  # ODT_SAT => XYZ => D60_2_D65 => sRGB
  ACESOutputMat = np.array([
    [ 1.60475, -0.10208, -0.00327], # transposed from source
    [-0.53108,  1.10813, -0.07276],
    [-0.07367, -0.00605,  1.07602],
  ])

  img = img@ACESInputMat
  img = RRTAndODTFit( img );
  img = img@ACESOutputMat

  return img;

And there's this additional pre-tonemapping scale factor of exposure/0.6 (where exposure defaults to 1.0), as explained in the three.js shader

jasondavies commented 1 year ago

@bsdorra This is great, thanks! Is there any chance you can also share:

I'm tempted to ask for create_hdri_light too, just in case it helps. Thanks!

bsdorra commented 1 year ago

@jasondavies Here's the code that I use to calculate relevant cam params


target = np.array([scenario["target"]["x"], scenario["target"]["y"], scenario["target"]["z"]])
theta = scenario["orbit"]["theta"]
phi = scenario["orbit"]["phi"]
radius = scenario["orbit"]["radius"]
fovy = scenario["verticalFoV"]
aspect = resolution[0]/resolution[1]

theta = theta * math.pi / 180;
phi = phi * math.pi / 180;
radius_sin_phi = radius * math.sin(phi);
cam_pos = [
  radius_sin_phi * math.sin(theta) + target[0],
  radius * math.cos(phi) +  target[1], #y-up
  radius_sin_phi * math.cos(theta) +  target[2],
]

center = np.array([target[0], target[1], target[2]]);

if (radius <= 0):
  center[0] = cam_pos[0] - math.sin(phi) * math.sin(theta)
  center[1] = cam_pos[1] - math.cos(phi) 
  center[2] = cam_pos[2] - math.sin(phi) * math.cos(theta)

view_matrix = look_at(cam_pos, center, up) 
focal_length = np.linalg.norm(center-cam_pos)

sensor_height = math.tan(fovy*0.5* math.pi / 180.0) * focal_length * 2.0
sensor_width = sensor_height * aspect

create_hdr_light won't help, it just forwards the IBL image path to the renderer's loading function. But there's nothing special going on in there, just reading the image data and storing it as texture for rendering.

If necessary, pre-loading image data into an array is straight forward with imageio

data = imageio.imread(ibl_path, format='HDR-FI')
bhouston commented 1 year ago

Thank you @bsdorra for your help on this issue! It wasn't possible to achieve these quality results without your example code!