# WebGl通过网址动态加载网络地址模型

Posted weixin_43806095

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了# WebGl通过网址动态加载网络地址模型相关的知识,希望对你有一定的参考价值。

WebGl通过网址动态加载网络地址模型

前期需要准备的资源:
1..gbl后缀的模型文件,其中包含了纹理、贴图等模型所依赖的文件,放在unity可以直接使用,不用在后续使用代码添加材质。谨记:模型文件、贴图文件不要出现中文,json反序列化时会报错的。
2.添加**GLTFUtility-master插件下载**
3.从Unity Window->PackageManage中输入com.unity.nuget.newtonsoft-json包名称添加 Newtonsoft.Json-for-Unity

.glb文件处理

限制了同时只能加载一个模型,进行等待和分优先级处理,直接看代码

using Siccity.GLTFUtility;
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.Networking;

/// <summary>
/// 加载优先级
/// </summary>
public enum WaitLoadFilePriority

    HIGH=0,
    LOW,
    MIDDLE,
    NUM,

//每一个需要加载的模型保存数据
public class AsyncLoadResParam

    public string filepath="";
    //有可能有多处需要加载模型,为避免重复加载,此处只进行回调函数的增加
    public List<Action<GameObject>> Callbacks = new List<Action<GameObject>>();


/// <summary>
/// 加载GLB模型单例
/// </summary>
public class GLTFUtilityScript 

    //存储所有未进行加载的,等待的文件
    protected List<AsyncLoadResParam>[] m_WaitLoadFiles=new List<AsyncLoadResParam>[(int)WaitLoadFilePriority.NUM];
    //当前正在加载的模型
    protected AsyncLoadResParam cuResParam;
    //是否加载中
    bool isLoading = false;
	//使用一个不会被隐藏的对象,需要自己在其他地方赋值
    public MonoBehaviour mono;
    
    private static GLTFUtilityScript instance;
    public static GLTFUtilityScript Instance
    
        get
        
            if (instance == null)
            
                instance = new GLTFUtilityScript();
            
            return instance;
        
    
    public void Init()
    
        for (int i = 0; i < (int)WaitLoadFilePriority.NUM; i++)
        
            m_WaitLoadFiles[i]=new List<AsyncLoadResParam>();
        
    
    /// <summary>
    /// 异步加载 gltf and glb   
    /// </summary>
    /// <param name="filepath">路径</param>
    protected void ImportGLTFAsync(string filepath)
    
        isLoading = true;
        mono.CancelInvoke(CheckLoadState);//每次加载时需要先结束上次检查事件
        mono.StartCoroutine(LoadModel(filepath, OnLoadComplete));
    
    /// <summary>
    /// 加载模型
    /// </summary>
    /// <param name="path"></param>
    /// <param name="callback"></param>
    /// <returns></returns>
    private IEnumerator LoadModel(string path,Action<GameObject> callback=null)
    
        var request = UnityWebRequest.Get(path);
        yield return request.SendWebRequest();
        if (request.result != UnityWebRequest.Result.Success)
        
            callback(null);
            Debug.LogError(request.error);
        
        else
        
            mono.Invoke(CheckLoadState,10f); //开启10s检测时间,防止处于卡死状态
            var obj= Importer.LoadFromBytes(request.downloadHandler.data);
            while (obj==null)  yield return null;
            callback(obj);
        
    
    //10s检测时间,如果文件错误的等无法加载的情况,终止当前加载,防止处于卡死状态
    private void CheckLoadState()
    
        if (isLoading)
        
            isLoading = false;
            for (int i = 0; i < cuResParam.Callbacks.Count; i++)
            
                Debug.Log("10s检测时间,防止处于卡死状态");
                cuResParam.Callbacks[i]?.Invoke(null);
            
            DeleteAsyncLoadResParam();
            CheckAsyncLoadResParam();
        
    
    /// <summary>
    /// 加载完后的回调
    /// </summary>
    /// <param name="result">加载出来的物体</param>
    /// <param name="clip"></param>
    void OnLoadComplete(GameObject obj)
    
        isLoading = false;
        for (int i = 0; i < cuResParam.Callbacks.Count; i++)
        
            cuResParam.Callbacks[i]?.Invoke(obj);
        
        DeleteAsyncLoadResParam();
        CheckAsyncLoadResParam();
    
    /// <summary>
    /// 检查字典里需要加载的资源,是否继续下载模型
    /// </summary>
    /// <returns></returns>
    protected void CheckAsyncLoadResParam()
    
        Debug.Log("检查是否继续执行");
        for (int i = 0; i <  m_WaitLoadFiles.Length; i++)
        
            if (m_WaitLoadFiles[i].Count>0)
            
                cuResParam=m_WaitLoadFiles[i][0];

                ImportGLTFAsync(m_WaitLoadFiles[i][0].filepath);
                return;
            
        
    
    /// <summary>
    /// 添加模型加载
    /// </summary>
    /// <param name="path"></param>
    /// <param name="callback"></param>
    public void AddAsyncLoadResParam(string path, Action<GameObject> callback=null,WaitLoadFilePriority priority=WaitLoadFilePriority.MIDDLE)
    
        Debug.Log("添加模型加载");
        for (int i = 0; i < m_WaitLoadFiles[(int)priority].Count; i++)
        
            if (m_WaitLoadFiles[(int)priority][i].filepath.Equals(path))
            
                if (callback!=null)
                
                    m_WaitLoadFiles[(int)priority][i].Callbacks.Add(callback);
                    return;
                
            
        
        var item = new AsyncLoadResParam();
        item.filepath = path;
        item.Callbacks.Add(callback);
        m_WaitLoadFiles[(int)priority].Add(item);
        if (!isLoading)
        
            CheckAsyncLoadResParam();
        
    
    
    /// <summary>
    /// 删除需要加载的资源
    /// </summary>
    /// <returns></returns>
    protected void DeleteAsyncLoadResParam()
    
        for (int i = 0; i <  m_WaitLoadFiles.Length; i++)
        
            if (m_WaitLoadFiles[i].Count>0)
            
                for (int j = 0; j < m_WaitLoadFiles[i].Count; j++)
                
                    if (cuResParam==m_WaitLoadFiles[i][j])
                    
                        m_WaitLoadFiles[i].Remove(m_WaitLoadFiles[i][j]);
                        return;
                    
                
            
        
    

调用示例

    private static Dictionary<string,GameObject> m_AlreadyLoadedDic=new Dictionary<string, GameObject>();
    //记得初始化
    public static void Init(MonoBehaviour temp)
    
        mono = temp;
        GLTFUtilityScript.Instance.Init();
    
    //供外界用来加载模型的调用
    public static void LoadModel(string filePath, Action<GameObject> callback)
    
        //文件名
        string fileName=filePath.Remove(0, filePath.LastIndexOf("/") + 1);
        GameObject temp = null;
        if (!m_AlreadyLoadedDic.TryGetValue(fileName,out temp) && temp==null)
        
            GLTFUtilityScript.Instance.AddAsyncLoadResParam(filePath, (go) =>
            
                if (!m_AlreadyLoadedDic.ContainsKey(fileName))
                
                    Debug.Log("成功保存模型到本地");
                    //添加到本地字典保存
                    m_AlreadyLoadedDic.Add(fileName,go);
                    callback.Invoke(go);
                
            );
        
        else
        
            callback.Invoke(temp);
        
    
//调用示例
		LoadModel(tidePlayInfo.workImgUrl, (go) =>
            
                if (go!=null)
                
                    var temp= Instantiate(go, transform);
                    temp.gameObject.SetActive(true);
          			 Debug.Log("模型加载成功");	
                
                else
                
                    Debug.Log("模型加载未成功");
                
           



踩坑笔记
1.使用 unity2021版本的同学,请使用GLTFUtility-master.7.2之前的版本,不要想着去卸载unity的依赖包newtonsoft-json,他没有问题!
2.WebGl开发的同学特别注意 补充!!!巨坑!!!
(1). 当你使用 JsonConvert.DeserializeObject方法打印出正确的信息后,就要知道序列化和反序列化是没有问题的
(2).当你排查很久发现C#的Task类不执行,任务状态一直处于WaitingToRun,不要考虑是因为WebGl不支持多线程的原因
解决方案 :加载glb模型请用 Importer.LoadFromBytes,不要使用Importer.LoadFromFileAsync,不要问,问就是血的教训。下面是执行代码,替换上面的加载glb模型调用方法。

 /// <summary>
    /// 加载模型
    /// </summary>
    /// <param name="path">路径</param>
    /// <param name="callback">回调方法</param>
    /// <returns></returns>
    private IEnumerator LoadModel(string path,Action<GameObject> callback=null)
    
        var request = UnityWebRequest.Get(path);
        yield return request.SendWebRequest();
        if (request.result != UnityWebRequest.Result.Success)
        
            Debug.LogError(request.error);
        
        else
        
           callback(Importer.LoadFromBytes(request.downloadHandler.data));
        
    

使用WebGL加载Google街景图

    我们要实现的功能比较简单:首先通过坐标定位、我的位置、地址搜索等方式,调用google map api获取地址信息。然后根据地址信息中的全景信息获取当前缩放级别的全景信息。最终把这些全景信息通过WebGL方法显示在屏幕上。

    了解了Google街景图的呈现原理,像国内的街景呈现,景区全景呈现不外乎都是相似的原理。区别只是调用的api不同而已。在实现功能过程中,我们也可以了解到全景呈现的一些原理。

    在介绍代码之前,先大概的描述下实现的步骤:

    1)、使用google api呈现地图,实现地图定位、搜索功能。这两种方式我们获取的都是地址信息,地址信息中我们关注两项数据:坐标、全景ID。

    2)、使用google提供的全景api,传递zoom和当前坐标位置以及全景ID。获取当前位置全景图片。

    3)、把获取的图片集合通过对应的坐标位置,呈现到同一个canvas上,拼凑成一张完整的图片。

    4)、把canvas作为一个纹理,贴到类型为Sphere的球体上,摄像头在球体中心位置观察。

    通过以上步骤就可实现google街景图的呈现。下面的内容将介绍具体的实现。

google地图呈现

    显示看下google地图的界面实现,界面如下:

image

    要使用google地图,先得引入googe api。如何引入,自己查看下google api介绍就知道了。这里需要特别注意的是,如果是本地调试,在获取api时,必须传递key。否则,地图就不能使用。

   界面实现代码如下:

    <div id="pano"></div>
    <div id="options" class="hide">
        <div id="map"></div>

        <div class="block">
            <form id="map_form">
                <input type="text" name="address" id="address" />
                <button type="submit" class="primary button" id="searchButton" >查询</button>
            </form>
        </div>

        <div class="block">
            <button type="submit" id="myLocationButton" style="width: 148px" class="button">使用我的位置</button>
            <button type="submit" id="fullscreenButton" style="width: 148px" class="button">全屏</button>
        </div>

        <div class="block">
            <b>质量</b>
            <form id="pano_form" style="position: absolute; right: 0; top: 0">
                <button name="scale" style="width: 4em" id="scale1" class="left button">低</button>
                <button name="scale" style="width: 6em" id="scale2" class="middle button">中</button>
                <button name="scale" style="width: 4em" id="scale3" class="middle button">高</button>
                <button name="scale" style="width: 7em" id="scale4" class="right button">超清</button>
            </form>
        </div>

        <div class="block" id="status" >
            <div id="message" ></div>
            <div id="error" ></div>
        </div>
    </div>

    <div id="preloader">
        <div id="bar"></div>
    </div>

    <script type="text/javascript" src="libs/GSVPano.js"></script>
    <script type="text/javascript" src="libs/three.js"></script>
    <script type="text/javascript" src="libs/RequestAnimationFrame.js"></script>
    <!-- 本地调试google map api, 必须申请key-->
    <script type="text/javascript" src="//maps.google.com/maps/api/js?key=AIzaSyA6idYqH50rvzc2QBZyBdJT_ipSH2DrABk&sensor=false"></script>

    pano是用来渲染全景图的容器。options是显示google地图的容器,可以使用我的位置定位、全屏、地址查询、切换缩放级别。也可以直接在地图上定位。

    以上的操作我们可以分为两类,一类是获取坐标,另一类是设置容器大小。像我的位置定位、地址查询、地图定位最终都是为了获取当前地址的经纬度。而设置zoom,最终改变的是绘制全景图的容器的大小。

    也就是说,我们操作有两个输出:location、zoom。接下来就介绍着两个输出的使用。

使用zoom设置全景容器大小

    界面上有四个按钮,设置Zoom的大小。事件绑定代码如下:

//设置zoom按钮,并绑定事件
            for(var j = 1; j < 5; j++){
                var el = document.getElementById("scale" + j);
                scaleButtons.push(el);
                (function(z){
                    el.addEventListener("click", function(e){
                        e.preventDefault();
                        setZoom(z);
                    }, false);
                })(j);
            }

    每个事件都会调用setZoom函数设置当前缩放级别。setZoom函数如下:

function setZoom( z ){
            zoom = z;
            loader.setZoom( z);
            for(var j = 0; j < scaleButtons.length; j++){
                scaleButtons[j].className = scaleButtons[j].className.replace("active", "");
                if(z == (j + 1)) scaleButtons[j].className += " active";
            }
            if(activeLocation) loader.load(activeLocation);
        }

    这里有两行代码比较重要:loader.setZoom( z ),以及if(activeLocation) ….。第二段代码是和坐标有关系的,现在先不介绍。loader是一个类型为GSVPANO的对象,所有和全景相关的代码都包含在这个类型中。这个对象包含了setZoom函数。setZoom函数代码如下:

this.setZoom = function(z){
        _zoom = z;
        console.log(z);
        this.adaptTextureToZoom();
    };

    代码调用了adaptTextureToZoom()函数,从字面上看,是为了把纹理适配到设置的Zoom大小上。代码如下:

this.adaptTextureToZoom = function(){
        var w = widths[ _zoom ],//当前zoom,一张图片的宽度
            h = heights[ _zoom ]; // 当前zoom,一张图片的高度

        _wc = Math.ceil(w / maxW); // x方向,图片数量
        _hc = Math.ceil(h / maxH); // y方向,图片数量

        _canvas = []; // canvas集合
        _ctx = []; //上下文集合

        var ptr = 0;
        for(var y = 0; y < _hc; y++){ // y方向
            for(var x = 0; x < _wc; x++ ){ // x方向
                var c = document.createElement("canvas"); //创建一个新的canvas
                if( x < (_wc - 1)) c.width = maxW;
                else c.width = w - (maxW * x);
                if( y < (_hc - 1)) c.height = maxH;
                else c.height = h - (maxH * y );

                console.log("新创建canvas:" + c.width + " * " + c.height );
                _canvas.push( c );
                _ctx.push(c.getContext("2d"));
                ptr++;
            }
        }
    };

    这段代码我也花了一些时间研究,这里有几个参数需要先说明下:

var widths = [416, 832, 1664, 3328, 6656],
        heights = [416, 416, 832, 1664, 3328];
...
 if(gl){
        var maxTexSize = gl.getParameter(gl.MAX_TEXTURE_SIZE);
        maxW = maxH = maxTexSize;
    }
...

    widths和heights存放了每个缩放级别,一张图片的宽度和高度。maxW、maxH为纹理默认的最大尺寸。

    adaptTextureToZoom函数中,先获取每张图片的尺寸w、h。然后使用Math.ceil(w / maxW)获取在x方向,图片的个数。但通过我自的调试发现,w、h都是小于maxW、maxH的。所以x、y方向的图片个数_wc、_hc等为1。有了这样的结论,继续看adaptTextureToZoom函数下面的代码。使用for循环遍历依次遍历每一行,每一列。每个位置创建一个canvas。但由于_wc和_hc都为1,所有仅仅会创建一个canvas。

    并且这唯一一个canvas的宽度为w、高度为h。_canvas和_ctx集合的长度都为1。

    我们在界面上设置zoom,也就是为了获取这个canvas。以提供绘制全景图的容器。但这里有个奇怪的地方,widths和height的尺寸都是416的倍数。而接下里我们获取的全景图,每张的大小确实512。先把这个问题留着。继续看location的使用。

使用location获取当前位置的全景信息

    我们先回到之前介绍的setZoom函数,代码如下:

function setZoom( z ){
            zoom = z;
            loader.setZoom( z);
            ...
            if(activeLocation) loader.load(activeLocation);
        }

    现在我们就来分析loader.load(activeLocation)函数。activeLocation表示我们已经获取到的位子的坐标经纬度信息。load函数代码如下:

this.load = function(location){
        var self = this;
        var url = \'https://cbks0.google.com/cbk?cb_client=maps_sv.tactile&authuser=0&hl=en&output=polygon&it=1%3A1&rank=closest&ll=\' + location.lat() + \',\' + location.lng() + \'&radius=350\';

        var http_request = new XMLHttpRequest();
        http_request.open("GET", url, true);
        http_request.onreadystatechange = function(){
            if(http_request.readyState === 4 && http_request.status == 200){
                var data = JSON.parse(http_request.responseText);
                self.loadPano(location, data.result[0].id);
            }
        }
        http_request.send(null);
    }

    代码其实很简单,通过传递的location组装获取地址信息的url。然后使用XMLHttpRequest发送请求。返回的结果信息中,我们主要用到的是地址的id:data.result[0].id。请求回调函数最后调用了self.loadPano(location, data.result[0].id)函数。该函数代码如下:

this.loadPano = function(location, id){
        var self = this;
        _panoClient.getPanoramaById(id, function(result, status){
            if(status === google.maps.StreetViewStatus.OK){
                …                
                _panoId = result.location.pano;
                self.location = location;
                self.composePanorama();
            }else{
                if(self.onNoPanoramaData) self.onNoPanoramaData(status);
                self.throwError("不能获取panorama,原因如下:" + status);
            }
        });
    };

    _panoClient是类型为google.maps.StreetViewService的一个google api对象。包含有getPanoramaById(id, callback)函数。该函数通过传递的地址信息id获取全景信息。这里我们最关系的数据是_panoId(全景信息id)。获取了全景信息id,最后继续调用了self.composePanorama()函数。

this.composePanorama = function(){
        this.setProgress(0);

        var w = levelsW[ _zoom], //x方向的个数
            h = levelsH[ _zoom], //y方向的个数
            self = this,
            url,
            x,
            y;

        _count = 0;
        _total = w * h;

        var self = this;
        for(var y = 0; y < h; y++){
            for(var x = 0; x < w; x++){
                //var url = \'https://cbks2.google.com/cbk?cb_client=maps_sv.tactile&authuser=0&hl=en&panoid=\' + _panoId + \'&output=tile&zoom=\' + _zoom + \'&x=\' + x + \'&y=\' + y + \'&\' + Date.now();
                var url = \'https://geo0.ggpht.com/cbk?cb_client=maps_sv.tactile&authuser=0&hl=en&panoid=\' + _panoId + \'&output=tile&x=\' + x + \'&y=\' + y + \'&zoom=\' + _zoom + \'&nbt&fover=2\';

                (function(x, y){
                    if(_parameters.useWebGL){
                        var texture = THREE.ImageUtils.loadTexture(url, null, function(){
                            self.composeFromTile(x, y, texture);
                        });
                    }else{
                        var img = new Image();
                        img.addEventListener("load", function(){
                            self.composeFromTile(x, y, this);
                        });
                        img.crossOrigin = "";
                        img.src = url;
                    }
                })(x, y);
            }
        }
    };

    这里又有两个新变量levelsW、levelsH。还是先看下赋值:

var levelsW = [1, 2, 4, 7, 13, 26];
var levelsH = [1, 1, 2, 4, 7, 13];

    levelsW存放每个级别x方向图片个数,levelsH存放每个级别y方向图片个数。composePanorama函数中的_total变量有什么用?y用处也不大,加载进度需要用一下。接着每行每列一次遍历每个格子。假如当前zoom级别为2,那么对应的w和h分别是4,2。如下图所示:

image    遍历上图的每个格子,通过格式的编号x、y组装图片地址,创建一个Image对象(这里我们没有设置useWebGL),设置src等于图片地址url。这样Image就获取到了图片信息。图片加载成功后,调用self.composeFromTile(x, y, this)函数。composeFormTile函数代码如下:

this.composeFromTile = function(x, y, texture){
        x *= 512; //x方向编号编号所在像素位置
        y *= 512; // y方向编号所在像素位置
        var px = Math.floor(x / maxW), py = Math.floor( y / maxH); // px和py表示当前图片所在像素位置

        x -= px * maxW;
        y -= py * maxH;

        _ctx[py * _wc + px].drawImage(texture, 0, 0, texture.width, texture.height, x, y, 512, 512); //每个canvas上画图的位置和地图上x、y对应上。
        this.progress();
    }

    函数包含三个参数,前两个参数表示格子的位子,而texture表示图片对象。x、y都乘以了512,意思是把格子编号转换为格子左上角坐在的像素坐标。在计算px和py时,这里有个疑问,由于x和y始终是小于maxW和maxH的。所有px和py都等于0。那么x和y的像素位置是没改变的。并且py * _wc + px是指都是0。也就是说,所有格子对应的图片都是存放在_ctx[0]的画布上。这里的drawImage函数需要着重说明下,drawImage函数声明如下:

void ctx.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

    呈现如下:

drawImage

    调用drawImage函数传递的参数,texture表示image对象,第二个参数和第三个参数都为0,表示我们从图片对象上的左上角(0, 0)开始获取,获取的宽度为texture.width,获取的高度为texture.height。也就是说获取整个图片。

    接下来是后面的四个参数:x、y表示从canvas上的x、y坐标处开始绘制,绘制的大小为(512,512)。x和y不同,绘制的其实坐标就不一样。最终,我们可以把所有的全景格子图像绘制到canvas上。并呈现出一张完整的全景纹理图。

    这里又要提出:为什么这里设置图片尺寸为512 * 512,而画布大小却是416的倍数(而不是512的倍数)?

    google地图返回的图片尺寸都是512 * 512,但由于全景相机拍摄有部分是盲区,所有最后一行图片的底下部分都是黑色的被丢弃的。这部分黑色的高度正好是yc * (512 - 416)。我们截取一张最后一行的全景图片:

image

    很明显的看到底部的黑色区域。如果你手动测试,会发现它的高度正好是yc * (512 – 416)。如果黑色部分被丢弃,算下来,相当于每个图片的大小实际为416。这就是为什么画布画每个格子的尺寸是512。而实际画布大小是416的倍数。

    现在一个绘制了全景图的完整canvas已经准备好了,剩下的就是把它当做纹理绘制到球体网格上。当加载绘制完了。会触发onPanoramaLoad事件。该事件注册的 函数为:

loader.onPanoramaLoad = function(){
                activeLocation = this.location;
                mesh.material.uniforms.map.value = new THREE.Texture( this.canvas[0]);
                mesh.material.uniforms.map.value.needsUpdate = true;
                showMessage(\'Panorama tiles loaded.<br/>The images are \' + this.copyright);
                showProgress( false );
            }

    首先需要更新网格上材质的map属性,重新创建一个纹理对象赋值给它。这里直接把canvas作为图片传递给了Texture构造函数。下一行代码设置了map的needsUpdate属性为true,表示渲染时需要重新绘制纹理了。

介绍完了

   通过以上的介绍,我们应该能大概明白,全景图究竟是如何通过WebGL呈现出来的。也明白了在渲染过程中的一些全景技术。完整的带还包括了像全景图的旋转等功能,这里就不在做介绍了。需要了解的可以以下地址获取实例完整代码:

https://github.com/heavis/threejs-demo/tree/master/google_street 

    代码中有我申请的google map api,可直接使用。另外,如果想查看实例,自己还得有个FQ工具。over了!!!

以上是关于# WebGl通过网址动态加载网络地址模型的主要内容,如果未能解决你的问题,请参考以下文章

2023网络爬虫 -- 获取动态加载数据

使用WebGL加载Google街景图

SuperMap for WebGL 9D 加载平面坐标系三维场景

使用WebGL 自定义 3D 摄像头监控模型

WebGL入门(四十三)-WebGL加载OBJ-MTL三维模型

制作gltf动态夜景模型