本文是Microsoft的Web开发技术系列的一部分。 感谢您支持使SitePoint成为可能的合作伙伴。
您可能已经注意到,去年我们第一次谈论了babylon.js ,最近发布了带有3D声音定位(使用WebAudio)和体积光散射的babylon.js v2.0 。
如果您错过了v1.0公告,那么首先您可以在此处进行第二天的主题演讲,直接进入2:24-2:28。 在其中,Microsoft宣传员Steven Guggenheimer和John Shewchuk演示了如何在Babylon.js中添加Oculus Rift支持。 这个演示的关键内容之一就是我们在特定的着色器上所做的工作,以模拟镜头,如您在此图中看到的:
我还与Frank Olivier和Ben Constable举行了有关IE和Babylon.js上图形的会议。
这使我想到了有关babylon.js的一个常见问题:着色器是什么意思? 因此,今天我将尝试向您解释着色器的工作原理。
理论
在开始实验之前,我们必须首先了解事物在内部如何工作。
在处理硬件加速的3D时,我们讨论了两个CPU:主CPU和GPU。 GPU是一种非常专业的CPU。
GPU是您使用CPU设置的状态机。 例如,CPU将配置GPU渲染线条而不是三角形。 否则它将定义透明性等等。
设置完所有状态后,CPU将定义要渲染的内容(几何图形,该几何图形由点列表(称为顶点,并存储在称为顶点缓冲区的数组中)和索引列表(面或三角形)组成—存储到称为索引缓冲区的数组中)。
CPU的最后一步是定义如何渲染几何图形,对于此特定任务,CPU将为GPU定义着色器。 着色器是GPU将针对其必须渲染的每个顶点和像素执行的一段代码。
首先介绍一些词汇:将一个顶点(有多个顶点时的顶点)视为3D环境中的“点”,而不是2D环境中的点。
着色器有两种:顶点着色器和像素(或片段)着色器。
图形管线
在深入了解着色器之前,让我们退后一步。 为了渲染像素,GPU将采用CPU定义的几何形状并执行以下操作:
使用索引缓冲区,可以收集三个顶点以定义一个三角形:索引缓冲区包含一个顶点索引列表。 这意味着索引缓冲区中的每个条目都是顶点缓冲区中的顶点数。 这对于避免重复顶点非常有用。 例如,以下索引缓冲区是2个面的列表:[1 2 3 1 3 4]。 第一个面包含顶点1,顶点2和顶点3。第二个面包含顶点1,顶点3和顶点4。因此,此几何图形中有4个顶点:
顶点着色器将应用于三角形的每个顶点。 顶点着色器的主要目标是为每个顶点(3D顶点在2D屏幕上的投影)生成一个像素:
使用这3个像素(在屏幕上定义2d三角形),GPU将对附加到该像素的所有值(至少是其位置)进行插值,并将像素着色器应用于2d三角形中包含的每个像素,以便生成每个像素的颜色:
对于索引缓冲区定义的每个面孔都执行此过程。
显然,由于其并行性,GPU能够同时处理大量面孔的这一步骤,然后实现非常好的性能。
GLSL
我们刚刚看到,要渲染三角形,GPU需要两个着色器:顶点着色器和像素着色器。 这些着色器是使用称为GLSL(图形库着色器语言)的语言编写的。 看起来像C。
对于Internet Explorer 11,我们已经开发了将GLSL转换为HLSL(高级着色器语言)的编译器,后者是DirectX 11的着色器语言。这使IE11可以确保着色器代码安全(您不想重置您的使用WebGL的计算机):
precision highp float;
// Attributes
attribute vec3 position;
attribute vec2 uv;
// Uniforms
uniform mat4 worldViewProjection;
// Varying
varying vec2 vUV;
void main(void) {
gl_Position = worldViewProjection * vec4(position, 1.0);
vUV = uv;
}
顶点着色器结构
顶点着色器包含以下内容:
- 属性 :属性定义了顶点的一部分。 默认情况下,顶点至少应包含一个位置(
vector3:x, y, z
)。 但是作为开发人员,您可以决定添加更多信息。 例如,在以前的着色器中,有一个名为uv
的vector2
(纹理坐标,允许在3D对象上应用2D纹理) - 制服 :制服是着色器使用的变量,由CPU定义。 我们这里唯一的统一是一个矩阵,用于将顶点(x,y,z)的位置投影到屏幕(x,y)
- 变化 :变化变量是由顶点着色器创建并传输到像素着色器的值。 此处,顶点着色器会将
vUV
(uv
的简单副本)值传输到像素着色器。 这意味着此处定义了具有位置和纹理坐标的像素。 这些值将由GPU内插,并由像素着色器使用。 - main :名为main的函数是GPU为每个顶点执行的代码,并且必须至少产生gl_position的值(当前顶点在屏幕上的位置)。
我们可以在示例中看到顶点着色器非常简单。 它产生一个系统变量(开始用gl_
命名) gl_position
来定义相关联的像素的位置,并将其设置称为变可变vUV
。
矩阵背后的伏都教
在我们的着色器中,我们有一个名为worldViewProjection
的矩阵。 我们使用此矩阵将顶点位置gl_position
到gl_position
变量。 那很酷,但是我们如何得到这个矩阵的值呢? 它是统一的,因此我们必须在CPU端定义它(使用JavaScript)。
这是执行3D的复杂部分之一。 您必须了解复杂的数学运算(否则,您将不得不使用3D引擎(如babylon.js),我们稍后将介绍)。
worldViewProjection
矩阵是3种不同矩阵的组合:
使用结果矩阵,我们可以将3d顶点转换为2d像素,同时考虑到视点以及与当前对象的位置/比例/旋转相关的所有内容。
作为3D开发人员,这是您的责任:创建并保持此矩阵为最新。
返回着色器
在每个顶点上执行顶点着色器后(然后执行三次),我们将获得三个像素,它们具有正确的gl_position
和_vUV _value。 然后,GPU将在这些像素产生的三角形中包含的每个像素上插值这些值
然后,对于每个像素,它将执行像素着色器:
precision highp float;
varying vec2 vUV;
uniform sampler2D textureSampler;
void main(void) {
gl_FragColor = texture2D(textureSampler, vUV);
}
像素(或片段)着色器结构
像素着色器的结构类似于顶点着色器:
- 变化 :变化变量是由顶点着色器创建并传输到像素着色器的值。 此处,像素着色器将从顶点着色器接收vUV值。
- 制服 :制服是着色器使用的变量,由CPU定义。 我们这里唯一的制服是采样器,它是一种用于读取纹理颜色的工具。
- main :名为main的函数是GPU为每个像素执行的代码,并且至少必须产生
gl_FragColor
的值(当前像素的颜色)。
这个像素着色器非常简单:它使用来自顶点着色器的纹理坐标从纹理中读取颜色(后者又从顶点获取)。
您是否要查看这种着色器的结果? 这里是:
(你可以看到完整的工作代码在我的博客点击这里 )
为了获得这个结果,您将不得不处理大量的WebGL代码。 的确,WebGL是一个功能强大但水平很低的API,从创建缓冲区到定义顶点结构,您必须自己做所有事情。 您还必须进行所有数学运算并设置所有状态并处理纹理加载等等。
太难? BABYLON.Shader救援材料
我知道您在想什么:着色器确实很棒,但是我不想打扰WebGL内部管道甚至数学。
你是对的! 这是一个完全合法的问题,这就是我创建Babylon.js的原因。
让我为您介绍上一个滚动球演示所使用的代码。 首先,您将需要一个简单的网页:
< !DOCTYPE html>
<html>
<head>
<title>Babylon.js</title>
<script src="Babylon.js"></script>
<script type="application/vertexShader" id="vertexShaderCode">
precision highp float;
// Attributes
attribute vec3 position;
attribute vec2 uv;
// Uniforms
uniform mat4 worldViewProjection;
// Normal
varying vec2 vUV;
void main(void) {
gl_Position = worldViewProjection * vec4(position, 1.0);
vUV = uv;
}
</script>
<script type="application/fragmentShader" id="fragmentShaderCode">
precision highp float;
varying vec2 vUV;
uniform sampler2D textureSampler;
void main(void) {
gl_FragColor = texture2D(textureSampler, vUV);
}
</script>
<script src="index.js"></script>
<style>
html, body {
width: 100%;
height: 100%;
padding: 0;
margin: 0;
overflow: hidden;
margin: 0px;
overflow: hidden;
}
#renderCanvas {
width: 100%;
height: 100%;
touch-action: none;
-ms-touch-action: none;
}
</style>
</head>
<body>
<canvas id="renderCanvas"></canvas>
</body>
</html>
您会注意到,着色器是由script
标签定义的。 使用Babylon.js,您还可以在单独的文件(.fx文件)中定义它们。
您可以在此处或在我们的GitHub存储库上获取babylon.js。 您必须使用1.11或更高版本才能访问BABYLON.StandardMaterial。
最后,主要的JavaScript代码如下:
"use strict";
document.addEventListener("DOMContentLoaded", startGame, false);
function startGame() {
if (BABYLON.Engine.isSupported()) {
var canvas = document.getElementById("renderCanvas");
var engine = new BABYLON.Engine(canvas, false);
var scene = new BABYLON.Scene(engine);
var camera = new BABYLON.ArcRotateCamera("Camera", 0, Math.PI / 2, 10, BABYLON.Vector3.Zero(), scene);
camera.attachControl(canvas);
// Creating sphere
var sphere = BABYLON.Mesh.CreateSphere("Sphere", 16, 5, scene);
var amigaMaterial = new BABYLON.ShaderMaterial("amiga", scene, {
vertexElement: "vertexShaderCode",
fragmentElement: "fragmentShaderCode",
},
{
attributes: ["position", "uv"],
uniforms: ["worldViewProjection"]
});
amigaMaterial.setTexture("textureSampler", new BABYLON.Texture("amiga.jpg", scene));
sphere.material = amigaMaterial;
engine.runRenderLoop(function () {
sphere.rotation.y += 0.05;
scene.render();
});
}
};
您可以看到,我使用BABYLON.ShaderMaterial
摆脱了编译,链接和处理着色器的所有负担。
创建BABYLON.ShaderMaterial
,必须指定用于存储着色器的DOM元素或着色器所在文件的基本名称。 如果选择使用文件,则必须为每个着色器创建一个文件,并使用以下模式basename.vertex.fx
和basename.fragment,.fx
。 然后,您将必须创建如下材料:
var cloudMaterial = new BABYLON.ShaderMaterial("cloud", scene, "./myShader",{
attributes: ["position", "uv"],
uniforms: ["worldViewProjection"]
});
您还必须指定使用的属性和制服的名称。
然后,您可以使用setTexture
, setFloat
, setFloats
, setColor3
, setColor4
, setVector2
, setVector3
, setVector4
, setMatrix functions
直接设置制服和采样器的值。
您还记得以前的worldViewProjection
矩阵吗? 使用Babylon.js和BABYLON.ShaderMaterial
,您无需担心! BABYLON.ShaderMaterial
会自动为您计算它,因为您在制服列表中声明了它。 BABYLON.ShaderMaterial
还可以为您处理以下矩阵:
- 世界
- 视图
- 投影
- 世界观
- worldViewProjection
不再需要数学。 例如,每次执行sphere.rotation.y += 0.05
,都会为您生成球体的世界矩阵并将其传输到GPU。
CYOS:创建自己的着色器
因此,让我们做得更大,创建一个页面,您可以在其中动态创建自己的着色器并立即查看结果。 该页面将使用我们之前讨论的相同代码,并将使用BABYLON.ShaderMaterial
对象编译和执行将要创建的着色器。
我使用CYOS的ACE代码编辑器。 这是一个令人难以置信的代码编辑器,具有语法突出显示功能。 随时在这里看看。 您可以在这里找到CYOS。
使用第一个组合框,您将能够选择预定义的着色器。 我们将在之后看到他们每个人。
您还可以使用第二个组合框更改用于预览着色器的网格(3D对象)。
编译按钮用于从着色器创建新的BABYLON.ShaderMaterial
。 此按钮使用的代码如下:
// Compile
shaderMaterial = new BABYLON.ShaderMaterial("shader", scene, {
vertexElement: "vertexShaderCode",
fragmentElement: "fragmentShaderCode",
},
{
attributes: ["position", "normal", "uv"],
uniforms: ["world", "worldView", "worldViewProjection"]
});
var refTexture = new BABYLON.Texture("ref.jpg", scene);
refTexture.wrapU = BABYLON.Texture.CLAMP_ADDRESSMODE;
refTexture.wrapV = BABYLON.Texture.CLAMP_ADDRESSMODE;
var amigaTexture = new BABYLON.Texture("amiga.jpg", scene);
shaderMaterial.setTexture("textureSampler", amigaTexture);
shaderMaterial.setTexture("refSampler", refTexture);
shaderMaterial.setFloat("time", 0);
shaderMaterial.setVector3("cameraPosition", BABYLON.Vector3.Zero());
shaderMaterial.backFaceCulling = false;
mesh.material = shaderMaterial;
资料准备好向您发送三个预先计算的矩阵( world
, worldView
和worldViewProjection
)。 顶点将带有位置,法线和纹理坐标。 还已经为您加载了两个纹理:
最后是renderLoop
,我在其中更新了两个方便的制服:
- 一个叫
time
是为了得到一些有趣的动画 - 一种称为
cameraPosition
用于将相机的位置放入着色器中(对于照明方程式很有用)
engine.runRenderLoop(function () {
mesh.rotation.y += 0.001;
if (shaderMaterial) {
shaderMaterial.setFloat("time", time);
time += 0.02;
shaderMaterial.setVector3("cameraPosition", camera.position);
}
scene.render();
});
由于我们在Windows Phone 8.1上所做的工作,我们还可以在Windows Phone上使用CYOS(始终是创建着色器的好时机):
基本着色器
因此,让我们从CYOS上定义的第一个着色器开始:基本着色器。
我们已经知道此着色器。 它计算gl_position
并使用纹理坐标为每个像素获取颜色。
要计算像素位置,我们只需要worldViewProjection
矩阵和顶点位置:
precision highp float;
// Attributes
attribute vec3 position;
attribute vec2 uv;
// Uniforms
uniform mat4 worldViewProjection;
// Varying
varying vec2 vUV;
void main(void) {
gl_Position = worldViewProjection * vec4(position, 1.0);
vUV = uv;
}
纹理坐标(uv)会未经修改地传输到像素着色器。
请注意,我们需要添加precision mediump float;
第一行是顶点着色器和像素着色器,因为Chrome需要它。 它定义为获得更好的性能,我们不使用全精度浮点值。
像素着色器甚至更简单,因为我们只需要使用纹理坐标并获取纹理颜色即可:
precision highp float;
varying vec2 vUV;
uniform sampler2D textureSampler;
void main(void) {
gl_FragColor = texture2D(textureSampler, vUV);
}
之前我们已经看到textureSampler
制服填充了“ amiga”纹理,因此结果如下:
黑白着色器
现在让我们继续一个新的着色器:黑白着色器。
该着色器的目标是使用前一个着色器,但仅具有黑白渲染模式。
为此,我们可以保留相同的顶点着色器。 像素着色器将稍作修改。
我们的第一个选择是仅采用一个组件,例如绿色组件:
precision highp float;
varying vec2 vUV;
uniform sampler2D textureSampler;
void main(void) {
gl_FragColor = vec4(texture2D(textureSampler, vUV).ggg, 1.0);
}
正如你所看到的,而不是使用.rgb(该操作被称为调酒 ),我们使用.ggg
。
但是,如果我们想要真正准确的黑白效果,那么计算亮度(考虑所有分量)应该是一个更好的主意:
precision highp float;
varying vec2 vUV;
uniform sampler2D textureSampler;
void main(void) {
float luminance = dot(texture2D(textureSampler, vUV).rgb, vec3(0.3, 0.59, 0.11));
gl_FragColor = vec4(luminance, luminance, luminance, 1.0);
}
点运算(或点积)的计算方式如下:
result = v0.x * v1.x + v0.y * v1.y + v0.z * v1.z
因此,在我们的情况下:
luminance = r * 0.3 + g * 0.59 + b * 0.11 (This values are based on the fact that human eye is more sensible to green)
听起来很酷,不是吗?
单元着色着色器
现在,让我们转到一个更复杂的着色器:“单元”阴影着色器。
这需要获取顶点法线和顶点在像素着色器中的位置。 因此,顶点着色器将如下所示:
precision highp float;
// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
// Uniforms
uniform mat4 world;
uniform mat4 worldViewProjection;
// Varying
varying vec3 vPositionW;
varying vec3 vNormalW;
varying vec2 vUV;
void main(void) {
vec4 outPosition = worldViewProjection * vec4(position, 1.0);
gl_Position = outPosition;
vPositionW = vec3(world * vec4(position, 1.0));
vNormalW = normalize(vec3(world * vec4(normal, 0.0)));
vUV = uv;
}
请注意,我们也使用world
矩阵,因为位置和法线存储时没有任何变换,我们必须应用世界矩阵来考虑对象的旋转。
像素着色器如下:
precision highp float;
// Lights
varying vec3 vPositionW;
varying vec3 vNormalW;
varying vec2 vUV;
// Refs
uniform sampler2D textureSampler;
void main(void) {
float ToonThresholds[4];
ToonThresholds[0] = 0.95;
ToonThresholds[1] = 0.5;
ToonThresholds[2] = 0.2;
ToonThresholds[3] = 0.03;
float ToonBrightnessLevels[5];
ToonBrightnessLevels[0] = 1.0;
ToonBrightnessLevels[1] = 0.8;
ToonBrightnessLevels[2] = 0.6;
ToonBrightnessLevels[3] = 0.35;
ToonBrightnessLevels[4] = 0.2;
vec3 vLightPosition = vec3(0, 20, 10);
// Light
vec3 lightVectorW = normalize(vLightPosition - vPositionW);
// diffuse
float ndl = max(0., dot(vNormalW, lightVectorW));
vec3 color = texture2D(textureSampler, vUV).rgb;
if (ndl > ToonThresholds[0])
{
color *= ToonBrightnessLevels[0];
}
else if (ndl > ToonThresholds[1])
{
color *= ToonBrightnessLevels[1];
}
else if (ndl > ToonThresholds[2])
{
color *= ToonBrightnessLevels[2];
}
else if (ndl > ToonThresholds[3])
{
color *= ToonBrightnessLevels[3];
}
else
{
color *= ToonBrightnessLevels[4];
}
gl_FragColor = vec4(color, 1.);
}
此着色器的目标是模拟光线,而不是计算平滑阴影,我们将考虑根据特定的亮度阈值应用光线。 例如,如果光强度在1(最大)和0.95之间,则将直接应用对象的颜色(从纹理中提取)。 如果强度在0.95和0.5之间,则颜色将衰减0.8倍,依此类推。
因此,此着色器有四个步骤:
- 首先我们声明阈值和水平常量
- 然后,我们需要使用phong方程计算光照(我们认为光照没有移动):
vec3 vLightPosition = vec3(0, 20, 10);
// Light
vec3 lightVectorW = normalize(vLightPosition - vPositionW);
// diffuse
float ndl = max(0., dot(vNormalW, lightVectorW));
每个像素的光强度取决于法线和光方向之间的角度。
- 然后得到像素的纹理颜色
- 最后,我们检查阈值并将色阶应用于颜色
结果看起来像一个卡通对象:
Phong着色器
我们在上一个着色器中使用了Phong方程的一部分。 因此,让我们现在尝试完全使用它。
此处的顶点着色器很简单,因为所有操作都将在像素着色器中完成:
precision highp float;
// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
// Uniforms
uniform mat4 worldViewProjection;
// Varying
varying vec3 vPosition;
varying vec3 vNormal;
varying vec2 vUV;
void main(void) {
vec4 outPosition = worldViewProjection * vec4(position, 1.0);
gl_Position = outPosition;
vUV = uv;
vPosition = position;
vNormal = normal;
}
根据等式,您必须使用光的方向和顶点的法线来计算漫反射和镜面反射部分 :
precision highp float;
precision highp float;
// Varying
varying vec3 vPosition;
varying vec3 vNormal;
varying vec2 vUV;
// Uniforms
uniform mat4 world;
// Refs
uniform vec3 cameraPosition;
uniform sampler2D textureSampler;
void main(void) {
vec3 vLightPosition = vec3(0, 20, 10);
// World values
vec3 vPositionW = vec3(world * vec4(vPosition, 1.0));
vec3 vNormalW = normalize(vec3(world * vec4(vNormal, 0.0)));
vec3 viewDirectionW = normalize(cameraPosition - vPositionW);
// Light
vec3 lightVectorW = normalize(vLightPosition - vPositionW);
vec3 color = texture2D(textureSampler, vUV).rgb;
// diffuse
float ndl = max(0., dot(vNormalW, lightVectorW));
// Specular
vec3 angleW = normalize(viewDirectionW + lightVectorW);
float specComp = max(0., dot(vNormalW, angleW));
specComp = pow(specComp, max(1., 64.)) * 2.;
gl_FragColor = vec4(color * ndl + vec3(specComp), 1.);
}
我们已经在上一个着色器中使用了扩散部分,因此在这里我们只需要添加镜面反射部分。 这张来自Wikipedia的图片很好地说明了着色器的工作原理:
我们这个领域的结果:
舍弃着色器
对于Discard着色器,我想介绍一个新概念: discard
关键字。
该着色器将丢弃每个非红色像素,并将创建被挖物体的幻觉。
顶点着色器与基本着色器使用的相同:
precision highp float;
// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
// Uniforms
uniform mat4 worldViewProjection;
// Varying
varying vec2 vUV;
void main(void) {
gl_Position = worldViewProjection * vec4(position, 1.0);
vUV = uv;
}
例如,当绿色分量过高时,其一侧的像素着色器将必须测试颜色并使用discard
:
precision highp float;
varying vec2 vUV;
// Refs
uniform sampler2D textureSampler;
void main(void) {
vec3 color = texture2D(textureSampler, vUV).rgb;
if (color.g > 0.5) {
discard;
}
gl_FragColor = vec4(color, 1.);
}
结果很有趣:
波浪着色器
我们在像素着色器方面做了大量工作,但我也想向您展示,我们可以使用顶点着色器做很多事情。
对于Wave着色器,我们将重用Phong像素着色器。
顶点着色器将使用统一的time
来获取一些动画值。 使用此制服,着色器将生成一个具有顶点位置的波:
precision highp float;
// Attributes
attribute vec3 position;
attribute vec3 normal;
attribute vec2 uv;
// Uniforms
uniform mat4 worldViewProjection;
uniform float time;
// Varying
varying vec3 vPosition;
varying vec3 vNormal;
varying vec2 vUV;
void main(void) {
vec3 v = position;
v.x += sin(2.0 * position.y + (time)) * 0.5;
gl_Position = worldViewProjection * vec4(v, 1.0);
vPosition = position;
vNormal = normal;
vUV = uv;
}
将窦施加到position.y
,结果如下:
球形环境映射
此教程大受本教程的启发。 我将让您阅读出色的文章,并使用相关的着色器。
菲涅耳着色器
最后,我想以我最喜欢的菲涅耳着色器结束本文。
该着色器用于根据视图方向和顶点法线之间的角度施加不同的强度。
顶点着色器与单元着色器使用的顶点着色器相同,我们可以轻松地在像素着色器中计算菲涅耳项 (因为我们具有可用于评估视图方向的法线和相机位置):
precision highp float;
// Lights
varying vec3 vPositionW;
varying vec3 vNormalW;
// Refs
uniform vec3 cameraPosition;
uniform sampler2D textureSampler;
void main(void) {
vec3 color = vec3(1., 1., 1.);
vec3 viewDirectionW = normalize(cameraPosition - vPositionW);
// Fresnel
float fresnelTerm = dot(viewDirectionW, vNormalW);
fresnelTerm = clamp(1.0 - fresnelTerm, 0., 1.);
gl_FragColor = vec4(color * fresnelTerm, 1.);
}
您的着色器?
现在,您已经准备好创建自己的着色器。 请随时使用此处的评论或下面链接的babylon.js论坛分享您的实验!
如果您想走得更远,这里有一些有用的链接:
还有更多信息:
或者,退后一步,我们团队的JavaScript学习系列:
- 使您的HTML / JavaScript更快的实用性能提示 (从响应设计到休闲游戏到性能优化的7个系列文章)
- 现代Web平台JumpStart (HTML,CSS和JS的基础知识)
- 使用HTML和JavaScript JumpStart开发通用Windows应用程序 (使用已经创建的JS来构建应用程序)
当然,我们总是欢迎您使用我们的一些免费工具来构建您的下一次Web体验: Visual Studio社区 , Azure试用版以及用于Mac,Linux或Windows的跨浏览器测试工具 。
本文是Microsoft的Web开发技术系列的一部分。 我们很高兴与您共享Project Spartan及其新的渲染引擎 。 获取免费的虚拟机或者在你的Mac,iOS设备,Android或Windows设备上远程测试modern.IE 。
From: https://www.sitepoint.com/mean-shaders-create-html5-webgl/