PixiJS源码分析系列:第二章 渲染在哪里开始?

第二章 渲染在哪里开始?

牢记,按第一章介绍的 npm start 启动本地调式环境才可进行调式

如果是 example 文件夹内的例子还需要 serve . 开启本地静态服务器

image

上一章介绍了 PixiJS 源码调式环境的安装,以及基本的调试方法。本章要研究一下它是如何渲染的

渲染大致步骤:

  1. 注册渲染器 renderer

  2. TickerPlugin 的 ticker 会自动开启并调用注册的回调函数 'TickerListener'

  3. 'TickerListener' 回调内调用 Application render 方法

  4. Application render 方法会调用渲染器 this.renderer.render(this.stage) 并传入 stage

  5. stage 是即是显示对像又是容器,所以只要渲染器开始调用 stage 的 render 方法,就会渲染 stage 下的所有子对象从而实现整颗显示对象树的渲染

还是以 example/simple.html 例子为例

<script type="text/javascript">
const app = new PIXI.Application({ width: 800, height: 600 });  
document.body.appendChild(app.view);  

const sprite = PIXI.Sprite.from('logo.png');  
sprite.x = 100;  
sprite.y = 100;  
sprite.anchor.set(0.5);  
sprite.rotation = Math.PI / 4;  
app.stage.addChild(sprite);  

app.ticker.add(() => {  
    sprite.rotation += 0.01;  
});  
</script>

sprite 是 Sprite 对象的实例, Sprite 实例继承自: Container -> DisplayObject -> EventEmitter

朔源至最顶层是 EventEmitter, 这是一个高性能事件库

EventEmitter https://github.com/primus/eventemitter3

至于为何它是高性能的,后面章节会顺便分析一下这个库

我们暂时不用去管这个 EventEmitter, 把它当做一个简单的事件收发库就行

先关注一下 DisplayObject,想要在画布中渲染,它必须得继承自 DisplayObject /packages/display/src/DisplayObject.ts

所有 DisplayObject 都继承自 EventEmitter, 可以监听事件, 触发事件

DisplayObject.ts 源码 210 行 可以看到它是一个抽象类

export abstract class DisplayObject extends utils.EventEmitter<DisplayObjectEvents>

以下显示对象都继承实现了这个抽象类

PIXI.Container
PIXI.Graphics 
PIXI.Sprite   
PIXI.Text     
PIXI.BitmapText    
PIXI.TilingSprite  
PIXI.AnimatedSprite
PIXI.Mesh     
PIXI.NineSlicePlane
PIXI.SimpleMesh    
PIXI.SimplePlane   
PIXI.SimpleRope    

DisplayObject 有一个叫 render 的抽你方法需要子类实现

abstract render(renderer: Renderer): void;

render 方法就是各子类显示对像需要自己去实现绘制自己的方法

回到 example/simple.html 文件

app.stage 就是 Application 类的 stage 属性,它是一个 Container 对象,继承自 DisplayObject

stage 可以看作就是一棵显示对象树,而最顶层就是渲染方法就是 Application 的 render 方法

Application 实例化时它自身公开的 render 方法就被 TickerPlugin 插件的 init 方法调用了

/packages/ticker/TickerPlugin.ts 源码 68 行

ticker.add(this.render, this, UPDATE_PRIORITY.LOW); // 在ticker 内添加了 render() 回调

只要 ticker 开启,就会调用 Application 实例的 render 方法

/packages/app/src/Application.ts 第 70 - 90 行 构造函数与 render 方法

constructor(options?: Partial<IApplicationOptions>)
{
    // The default options
    options = Object.assign({
        forceCanvas: false,
    }, options);

    this.renderer = autoDetectRenderer<VIEW>(options);
    // console.log('hello', 88888);
    // install plugins here
    Application._plugins.forEach((plugin) =>
    {
        plugin.init.call(this, options);
    });
}

/** Render the current stage. */
public render(): void
{
    this.renderer.render(this.stage);
}

this.renderer 就是渲染器,把 this.stage 整个传到渲染器内渲染

往 stage 内添加子显示对象其实就是往一个 Container 内添加子显示对象,当然由于 Container 继承自 DisplayObject,所以 Container 也需要实现自己的 render 方法

/packages/display/src/Container.ts

render(renderer: Renderer): void
{
    // 检测是否需要渲染
    if (!this.visible || this.worldAlpha <= 0 || !this.renderable)
    {
        return;
    }

    // 如果是特殊的对象需要特殊的渲染逻辑
    if (this._mask || this.filters?.length)
    {
        this.renderAdvanced(renderer);
    }
    else if (this.cullable)
    {
        this._renderWithCulling(renderer);
    }
    else
    {
        this._render(renderer);

        for (let i = 0, j = this.children.length; i < j; ++i)
        {
            this.children[i].render(renderer);
        }
    }
}

这个 render 方法很简单,它接受一个 renderer 调用自己的 _render 后再遍历子显示对象调用子显示对象公开的 render 方法

就是一个显示对象树,从顶层开始调用往树了枝叶遍历调用 render 从而实现显示对象树的渲染

有一点需要注意,render 方法内显示它如果是一个 mask 遮罩或自带 filters 滤镜,那么需要调用更高极的渲染方法 renderAdvanced 或 _renderWithCulling,否则它先自己 this._render(renderer);

Container 本身自己的 _render 是空的,意味着它本身不会被渲染,只会被子显示对象渲染,但是继承实现它的子类,比如 Sprite,会去实现自己的 _render 方法覆盖实现渲染

renderer 渲染器

渲染器从哪里来的?

进入渲染器看看

渲染器是由 Application 类的构造函数内 autoDetectRenderer 判断返回的

渲染器类型分为三类:

export enum RENDERER_TYPE
{
    /**
     * Unknown render type.
     * @default 0
     */
    UNKNOWN,
    /**
     * WebGL render type.
     * @default 1
     */
    WEBGL,
    /**
     * Canvas render type.
     * @default 2
     */
    CANVAS,
}

我们找到 StartupSystem.ts 文件内的 defaultOptions 对象,将 hello 设为 true

static defaultOptions: StartupSystemOptions = {
    /**
        * {@link PIXI.IRendererOptions.hello}
        * @default false
        * @memberof PIXI.settings.RENDER_OPTIONS
        */
    hello: true,
};

本地服务器下打开 example/simple.html, 浏览器控制台会输出

image

图 2-1

由输出的 PixiJS 7.3.2 - WebGL 2 可知,现在使用的是 WebGL 2

Renderer 类就是我们现在用到的渲染器 /packages/core/src/Renderer.ts

进入到 Renderer.ts 文件可以看到此类继承自 SystemManager 并实现了 IRenderer 接口

export class Renderer extends SystemManager<Renderer> implements IRenderer

进入构造函数:
/packages/core/src/Renderer.ts 第 292 - 364 行:

constructor(options?: Partial<IRendererOptions>)
{
    super();

    // Add the default render options
    options = Object.assign({}, settings.RENDER_OPTIONS, options);

    this.gl = null;

    this.CONTEXT_UID = 0;

    this.globalUniforms = new UniformGroup({
        projectionMatrix: new Matrix(),
    }, true);

    const systemConfig = {
        runners: [
            'init',
            'destroy',
            'contextChange',
            'resolutionChange',
            'reset',
            'update',
            'postrender',
            'prerender',
            'resize'
        ],
        systems: Renderer.__systems,
        priority: [
            '_view',
            'textureGenerator',
            'background',
            '_plugin',
            'startup',
            // low level WebGL systems
            'context',
            'state',
            'texture',
            'buffer',
            'geometry',
            'framebuffer',
            'transformFeedback',
            // high level pixi specific rendering
            'mask',
            'scissor',
            'stencil',
            'projection',
            'textureGC',
            'filter',
            'renderTexture',
            'batch',
            'objectRenderer',
            '_multisample'
        ],
    };

    this.setup(systemConfig);

    if ('useContextAlpha' in options)
    {
        if (process.env.DEBUG)
        {
            // eslint-disable-next-line max-len
            deprecation('7.0.0', 'options.useContextAlpha is deprecated, use options.premultipliedAlpha and options.backgroundAlpha instead');
        }
        options.premultipliedAlpha = options.useContextAlpha && options.useContextAlpha !== 'notMultiplied';
        options.backgroundAlpha = options.useContextAlpha === false ? 1 : options.backgroundAlpha;
    }

    this._plugin.rendererPlugins = Renderer.__plugins;
    this.options = options as IRendererOptions;
    this.startup.run(this.options);
}

Renderer 类内有一堆的 runners, plugins, systems

runners 即所谓的 signal '信号', 可以理解为 生命周期+状态变更时就会触发

plugins 即为 Renderer 所专门使用的插件

systems 即为 Renderer 所使用的系统,它由各个系统组合形成了渲染器 Renderer,以一辆车举例,'系统'可以理解组成车子的各个子系统,比如空调系统,油路系统,传动系统 等等

在构造函数中调用的 this.setup(systemConfig) 就是安装渲染函数所需要用到的系统,它来自 /packages/core/system/SystemManager.ts

进入 SystemManager.ts 找到 setup 方法:

setup(config: ISystemConfig<R>): void
{
    this.addRunners(...config.runners);

    // Remove keys that aren't available
    const priority = (config.priority ?? []).filter((key) => config.systems[key]);

    // Order the systems by priority
    const orderByPriority = [
        ...priority,
        ...Object.keys(config.systems)
            .filter((key) => !priority.includes(key))
    ];

    for (const i of orderByPriority)
    {
        this.addSystem(config.systems[i], i);
    }
    console.log('看看runners里是什么:',this.runners)
}

可以看到,创建了很多个 Runner 对象存储在 this.runners 内

在 setup 函数最后一行打印看看 runners 里存了些啥

image

图 2-3

可以看到各个 Runner 对象的 items 里保存了所有的 system 当 Runner 被调用时,也即触发调用 items 内系统

找到 addSystem 方法:

addSystem(ClassRef: ISystemConstructor<R>, name: string): this
{
    const system = new ClassRef(this as any as R);

    if ((this as any)[name])
    {
        throw new Error(`Whoops! The name "${name}" is already in use`);
    }
    
    (this as any)[name] = system;

    this._systemsHash[name] = system;

    for (const i in this.runners)
    {
        this.runners[i].add(system);
    }

    /**
        * Fired after rendering finishes.
        * @event PIXI.Renderer#postrender
        */

    /**
        * Fired before rendering starts.
        * @event PIXI.Renderer#prerender
        */

    /**
        * Fired when the WebGL context is set.
        * @event PIXI.Renderer#context
        * @param {WebGLRenderingContext} gl - WebGL context.
        */

    return this;
}

(this as any)[name] = system; 这一句就把 实例化后的 const system = new ClassRef(this as any as R); '系统' 按名称赋值到了 this 也即 Renderer 实例属性上了

所以通过 this.setup 后, 构造函数最后的 this.startup 属性 (StartupSystem) 可以访问,因为此时已经存在

根据注释,StartupSystem 就是用于负责初始化渲染器的,这是一切渲染的开始...

StartupSystem 的 run 方法 /packages/core/startup/StartupSystem.ts

第 56 - 69 行

run(options: StartupSystemOptions): void
{
    const { renderer } = this;
    console.log(renderer.runners.init)
    renderer.runners.init.emit(renderer.options);

    if (options.hello)
    {
        // eslint-disable-next-line no-console
        console.log(`PixiJS ${process.env.VERSION} - ${renderer.rendererLogId} - https://pixijs.com`);
    }

    renderer.resize(renderer.screen.width, renderer.screen.height);
}

第 58 行输出 console.log(renderer.runners.init) 看看名为 init 的 Runner 属性 items 内有 6 个系统需要触发 emit

image

图 2-3

再看看 Runner 类 /packages/core/runner/Runner.ts

根据注释:Runner是一种高性能且简单的信号替代方案。最适合在事件以高频率分配给许多对象的情况下使用(比如每帧!)

注释中举的例子已经很清晰的说明了 Runner 的使用场景了

Runner 类似 Signal 模式:

import { Runner } from '@pixi/runner';

const myObject = {
    loaded: new Runner('loaded'),
};

const listener = {
    loaded: function() {
        // Do something when loaded
    }
};

myObject.loaded.add(listener);

myObject.loaded.emit();

或用于处理多次调用相同函数

import { Runner } from '@pixi/runner';

const myGame = {
    update: new Runner('update'),
};

const gameObject = {
    update: function(time) {
        // Update my gamey state
    },
};

myGame.update.add(gameObject);

myGame.update.emit(time);

Signal 和 观察者模式 之间的主要区别在于实现方式和使用场景。观察者模式通常涉及一个主题(Subject)和多个观察者(Observers),主题维护观察者列表并在状态变化时通知观察者。

观察者模式更加结构化,观察者需要显式地注册和注销,而且通常是一对多的关系。

相比之下,Signal 更加简单和灵活,它通常用于处理单个事件或消息的订阅和分发。

Signal 不需要维护观察者列表,而是直接将事件发送给所有订阅者。

Signal 更加轻量级,适用于简单的事件处理场景,而观察者模式更适合需要更多结构和控制的情况。

renderer 的 render 函数

渲染器 Renderer 类内调用的 render 是名为 objectRenderer 的 ObjectRendererSystem 对象

render(displayObject: IRenderableObject, options?: IRendererRenderOptions): void
{
    this.objectRenderer.render(displayObject, options);
}

可以看到调用的是 ObjectRendererSystem 系统的 render 方法

/packages/core/src/render/ObjectRendererSystem.ts 第 49 - 125 行:

render(displayObject: IRenderableObject, options?: IRendererRenderOptions): void
{
    const renderer = this.renderer;

    let renderTexture: RenderTexture;
    let clear: boolean;
    let transform: Matrix;
    let skipUpdateTransform: boolean;

    if (options)
    {
        renderTexture = options.renderTexture;
        clear = options.clear;
        transform = options.transform;
        skipUpdateTransform = options.skipUpdateTransform;
    }

    // can be handy to know!
    this.renderingToScreen = !renderTexture;

    renderer.runners.prerender.emit();
    renderer.emit('prerender');

    // apply a transform at a GPU level
    renderer.projection.transform = transform;

    // no point rendering if our context has been blown up!
    if (renderer.context.isLost)
    {
        return;
    }

    if (!renderTexture)
    {
        this.lastObjectRendered = displayObject;
    }

    if (!skipUpdateTransform)
    {
        // update the scene graph
        const cacheParent = displayObject.enableTempParent();

        displayObject.updateTransform();
        displayObject.disableTempParent(cacheParent);
        // displayObject.hitArea = //TODO add a temp hit area
    }

    renderer.renderTexture.bind(renderTexture);
    renderer.batch.currentRenderer.start();

    if (clear ?? renderer.background.clearBeforeRender)
    {
        renderer.renderTexture.clear();
    }

    displayObject.render(renderer);

    // apply transform..
    renderer.batch.currentRenderer.flush();

    if (renderTexture)
    {
        if (options.blit)
        {
            renderer.framebuffer.blit();
        }

        renderTexture.baseTexture.update();
    }

    renderer.runners.postrender.emit();

    // reset transform after render
    renderer.projection.transform = null;

    renderer.emit('postrender');
}

displayObject.updateTransform(); 这一句,会遍历显示对象树,计算所有显示对象的 localTransform 和 worldTransform ,这对于正常渲染元素的样子与位置至关重要

displayObject.render(renderer); 这一句,也就是传进来的 stage 对象,遍历子显示对象的 render 并将渲染器传入。

最终会调用到 Sprite 内的 _render 方法就是我们加入到 stage 的 'logo.png'

/packages/sprite/src/Sprite.ts 的第 369 - 375 行

image

图 2-4

batch 就是 BatchSystem 的实例

batch 的当前渲染器 ExtensionType.RendererPlugin

再调用 batch 渲染器的 render(this) 将 this 即当前 Sprite 对象传入

batch 批处理渲染器

batch 渲染器定义 /packages/core/batch/src/BatchRenderer.ts

由 BatchRenderer.ts 定义的 extension 可知它是一个 ExtensionType.RendererPlugin 类型的扩展插件

在源码最后一行 extensions.add(BatchRenderer); 可知,它默认就被安装(实例化)到了 Renderer 上

正是由于默认被实例化安装了,所以才能在 图 2-5 Sprite.ts 的 _render 函数中调用 renderer.plugins[this.pluginName].render(this);

让我们看看 BatchRenderer.ts 的 render 函数

/**
 * Buffers the "batchable" object. It need not be rendered immediately.
 * @param {PIXI.DisplayObject} element - the element to render when
 *    using this renderer
 */
render(element: IBatchableElement): void
{
    if (!element._texture.valid)
    {
        return;
    }

    if (this._vertexCount + (element.vertexData.length / 2) > this.size)
    {
        this.flush();
    }

    this._vertexCount += element.vertexData.length / 2;
    this._indexCount += element.indices.length;
    this._bufferedTextures[this._bufferSize] = element._texture.baseTexture;
    this._bufferedElements[this._bufferSize++] = element;
}

可以看到,这个 render 并不是立即渲染,而是将渲染数据缓存起来,等到渲染的时候再进行渲染。

由这个类的注释信息可知,它的作用是先缓存需要渲染的 texture 数据,等待将 多个 texture 信息直接提交到GPU进行批量渲染, 以减少 draw 次数提高性能

在这个 render 函数最后一行加一个 debugger 看看

image
image

图 2-5

/packages/core/src/render/ObjectRendererSystem.ts 的 render 函数, 也就是第 104 - 107 行:

displayObject.render(renderer);

        // apply transform..
renderer.batch.currentRenderer.flush();

等到 displayObject.render(renderer); 显示对像树遍历收集完渲染数据后才 flush 推到 GPU

进入 /packages/core/batch/src/BatchRenderer.ts 找到 flush 第 625 - 646 行:

flush(): void
{
    if (this._vertexCount === 0)
    {
        return;
    }

    this._attributeBuffer = this.getAttributeBuffer(this._vertexCount);
    this._indexBuffer = this.getIndexBuffer(this._indexCount);
    this._aIndex = 0;
    this._iIndex = 0;
    this._dcIndex = 0;

    this.buildTexturesAndDrawCalls();
    this.updateGeometry();
    this.drawBatches();

    // reset elements buffer for the next flush
    this._bufferSize = 0;
    this._vertexCount = 0;
    this._indexCount = 0;
}

至此 flush() 函数,才是真正调用 webgl 处

_attributeBuffer 是一个 ViewableBuffer 的实例对象

而随后的 this.buildTexturesAndDrawCalls(); 会调用 buildTexturesAndDrawCalls -> buildDrawCalls -> packInterleavedGeometry

/packages/core/batch/src/BatchRenderer.ts 766 - 800 行

packInterleavedGeometry(element: IBatchableElement, attributeBuffer: ViewableBuffer, indexBuffer: Uint16Array,
    aIndex: number, iIndex: number): void
{
    const {
        uint32View,
        float32View,
    } = attributeBuffer;

    const packedVertices = aIndex / this.vertexSize;
    const uvs = element.uvs;
    const indicies = element.indices;
    const vertexData = element.vertexData;
    const textureId = element._texture.baseTexture._batchLocation;

    const alpha = Math.min(element.worldAlpha, 1.0);
    const argb = Color.shared
        .setValue(element._tintRGB)
        .toPremultiplied(alpha, element._texture.baseTexture.alphaMode > 0);

    // lets not worry about tint! for now..
    for (let i = 0; i < vertexData.length; i += 2)
    {
        float32View[aIndex++] = vertexData[i];
        float32View[aIndex++] = vertexData[i + 1];
        float32View[aIndex++] = uvs[i];
        float32View[aIndex++] = uvs[i + 1];
        uint32View[aIndex++] = argb;
        float32View[aIndex++] = textureId;
    }

    for (let i = 0; i < indicies.length; i++)
    {
        indexBuffer[iIndex++] = packedVertices + indicies[i];
    }
}

packInterleavedGeometry 内会将 element.vertexData 顶点数据, uvs, argb 等信息存入 attributeBuffer

indexBuffer 是用来存储 sprite 渲染时所需的顶点索引的缓冲区。

在渲染 sprite 时,引擎需要知道如何连接顶点以形成正确的形状,而这些连接顶点的顺序就是通过 _indexBuffer 中的数据来定义的。

每三个索引对应一个顶点,通过这些索引,引擎可以正确地连接顶点以渲染出 sprite 的形状。

如果你把 indexBuffer 打印出来可以看到有 12 个值, WebGL 绘制几何体都是由三角形组成的

矩形由2个三角形组成

let vertices = [
0.5, 0.5, 0.0,
-0.5, 0.5, 0.0,
-0.5, -0.5, 0.0, // 第一个三角形
-0.5, -0.5, 0.0,
0.5, -0.5, 0.0,
0.5, 0.5, 0.0, // 第二个三角形
]; // 矩形

有一条边是公共,这个时候可以索引缓冲区对象减少冗余的数据

索引缓冲对象全称是 Index Buffer Object(IBO),通过索引的方式复用已有的数据。

顶点位置数据只需要 4 个就足够了,公共数据使用索引代替。

const vertices = [
0.5, 0.5, 0.0, // 第 1 个顶点
-0.5, 0.5, 0.0, // 第 2 个顶点
-0.5, -0.5, 0.0, // 第 3 个顶点
0.5, -0.5, 0.0, // 第 4 个顶点
]; // 矩形

绘制模式为 gl.TRIANGLES 时,两个三角形是独立的,索引数据如下:

const indexData = [
0, 1, 2, // 对应顶点位置数据中 1、2、3 顶点的索引
0, 2, 3, // 对应顶点位置数据中 1、3、4 顶点的索引
]

这就是为什么Sprite.ts 类中 const indices = new Uint16Array([0, 1, 2, 0, 2, 3]); 如此定义的原因

相关知识可参考: https://segmentfault.com/a/1190000041144928

接下来是 this.updateGeometry(); 简单来说它它会创建几何模型 和 shader

最后调用 this.drawBatches() 内调用 gl.drawElements() 将前面缓存整理好的 buffer 绘制到 GPU

不管是 Canvas context 还是 WebGL 都是非对象的过程式的调用,PixiJS 的 Renderer 封装了这些操作,让开发者更专注于业务逻辑。

将过程式的调用封装成对象

WebGL 想要渲染,原理:

顶点着色器 + 片段着色器, 顶点着色器确定顶点位置,片段着色器确定每个片元的像素颜色

组成的着色程序 program 后通过 gl.drawArrays 或 gl.drawElements 运行一个着色方法对绘制到 GPU 上

我们采取先整体再细节的方式阅读源码,WebGL 具体渲染挺复杂的,暂时可以略过,如果有兴趣可以参考 WebGL 教程

这是一个很好的 WebGL 教程 https://webglfundamentals.org/webgl/lessons/zh_cn/webgl-fundamentals.html

本章小节

本章通过分析 webgl 渲染器,顺带看了部分 PixiJS 的 system/SystemManager "系统设计", 咋一看确实很复杂

优秀的设计时分值得借鉴,完全可以运用到自己的项目或组件库内

我对 webgl 了解的十分粗浅但借助 debugger 还是可以一步一步分析出逻辑走向,道阻且长啊

最新的 PixiJS 已经支持 WebGPU 渲染了,学不动了...


注:转载请注明出处博客园:王二狗Sheldon池中物 (willian12345@126.com)

原创作者: willian 转载于: https://www.cnblogs.com/willian/p/18307490
1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。
1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。1、资源项目源码均已通过严格测试验证,保证能够正常运行; 2、项目问题、技术讨论,可以给博主私信或留言,博主看到后会第一时间与您进行沟通; 3、本项目比较适合计算机领域相关的毕业设计课题、课程作业等使用,尤其对于人工智能、计算机科学与技术等相关专业,更为适合; 4、下载使用后,可先查看README.md文件(如有),本项目仅用作交流学习参考,请切勿用于商业用途。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值